Skip to content

ontocast.tool.facts_validation.critic_patch

Compile critic-proposed fixes into a validated graph patch, with no LLM call.

The loop's invariant is that every mutation is a compiled, validated GraphUpdate -- that is what keeps the two-namespace contract, IRI policy and literal repair in one place. It had drifted into a stricter and less defensible rule: that every mutation must come from a render call. The consequence was that a critic fix naming the exact triple to drop still cost a full re-extraction of the unit, so the loop only ever spent that on fixes it considered blocking and dropped the rest.

A fix that names the statements it removes and supplies the ones it adds is already a patch. Compiling it here costs nothing and leaves the invariant intact: the result goes through the same GraphUpdate the renderer's wire compiles to.

How a fix names what it removes matters more than anything else here. The original contract asked the critic to retype the offending statement into incorrect_value, and to match it had to reproduce the stored triple exactly -- same prefix form, same predicate, same literal shape. Across a large corpus of real critiques that reproduction succeeds a minority of the time for REPLACE and almost never for a bare REMOVE: the payload arrives as prose, as a plausible-but-invented IRI, or as a node-shaped quote spanning several statements with one predicate wrong, which fails the all-present check as a whole. Authoring new content in correct_value has no such problem, because nothing has to match.

So the primary path is by id: the critic is shown a numbered graph and cites :attr:TripleFix.triple_ids, which resolve by lookup. Requoting remains as a fallback for a fix that cites no id, under the same exact-match rule as before.

Still deliberately conservative. A cited id the index never issued sends the whole fix back rather than acting on the part that resolved; a statement already gone is not deleted again; and a fix that removes exactly what it re-adds is recorded as asking for nothing rather than counted as a fix that landed.

Attributes

logger = logging.getLogger(__name__) module-attribute

Classes

CompiledFixes dataclass

The mechanical half of a critique, split from the half needing a render.

Source code in ontocast/tool/facts_validation/critic_patch.py
@dataclass(frozen=True)
class CompiledFixes:
    """The mechanical half of a critique, split from the half needing a render."""

    #: Delete-then-insert patch over every fix in ``applied``, or ``None``
    #: when nothing compiled. The whole-pass view; ``patches`` is the same
    #: content one fix at a time.
    update: GraphUpdate | None = None
    #: One patch per fix in ``applied``, in the order proposed.
    patches: list[FixPatch] = field(default_factory=list)
    #: Fixes folded into ``update``.
    applied: list[TripleFix] = field(default_factory=list)
    #: Fixes that could not be applied; counted and reported, not retried.
    residual: list[TripleFix] = field(default_factory=list)
    #: Fixes whose delete set and insert set are the same statements. They ask
    #: for no change, so they are neither applied nor sent back as work -- but
    #: they are counted, because a critique made mostly of these is a critic
    #: producing motion rather than corrections.
    noop: list[TripleFix] = field(default_factory=list)
    #: Ids cited that the index never issued. Counted, never guessed at.
    bad_index_refs: int = 0
    #: True when the delete-share cap fired: every fix that removes something
    #: went back as residual and only the pure additions were kept.
    delete_capped: bool = False
    #: Delete halves withheld by screening, in fixes.
    deletes_refused: int = 0
    #: Fixes whose payload named a prefix nothing declares -- neither the
    #: payload's own ``@context`` nor the unit graph's bindings -- so the
    #: statement it meant could not be identified. Sent back as residual and
    #: counted here rather than applied with ``prefix:local`` as the IRI.
    unresolved_prefix: int = 0
    #: Fixes whose inserts would mint a placeholder: a subject named for an
    #: ignored token or artifact, or a new node carrying only annotations
    #: and no type. The critic's way of "resolving" a coverage finding for a
    #: number it could not place; refused and sent back rather than applied.
    junk_refused: int = 0
    #: Fixes whose payload typed a literal its lexical form cannot hold --
    #: the same defect the render path quarantines. Sent back as residual
    #: rather than written into the unit graph, where every later
    #: serialization and validation walk would re-derive the failed value.
    quarantined_literal: int = 0

Attributes

applied = field(default_factory=list) class-attribute instance-attribute
bad_index_refs = 0 class-attribute instance-attribute
delete_capped = False class-attribute instance-attribute
deletes_refused = 0 class-attribute instance-attribute
junk_refused = 0 class-attribute instance-attribute
noop = field(default_factory=list) class-attribute instance-attribute
patches = field(default_factory=list) class-attribute instance-attribute
quarantined_literal = 0 class-attribute instance-attribute
residual = field(default_factory=list) class-attribute instance-attribute
unresolved_prefix = 0 class-attribute instance-attribute
update = None class-attribute instance-attribute

Methods:

__init__(update=None, patches=list(), applied=list(), residual=list(), noop=list(), bad_index_refs=0, delete_capped=False, deletes_refused=0, unresolved_prefix=0, junk_refused=0, quarantined_literal=0)

CriticPatchPolicy dataclass

How much destruction one critic pass is allowed to do.

A compiled patch is transparent -- both halves are known before anything is touched -- so the limits are enforced by withholding the delete half rather than by inspecting the wreckage afterwards. Every rule here drops something and counts it; none of them raises.

Source code in ontocast/tool/facts_validation/critic_patch.py
@dataclass(frozen=True)
class CriticPatchPolicy:
    """How much destruction one critic pass is allowed to do.

    A compiled patch is transparent -- both halves are known before anything is
    touched -- so the limits are enforced by withholding the delete half rather
    than by inspecting the wreckage afterwards. Every rule here drops something
    and counts it; none of them raises.
    """

    #: Largest share of the target graph one pass may remove. A critique that
    #: wants to delete more than this has stopped correcting and started
    #: rewriting, which is the operation the loop deliberately does not offer.
    #: Over the cap the fixes that remove anything go back whole -- a REPLACE
    #: stripped of its delete half is an ADD of the new value beside the old
    #: one, a different edit from the one proposed -- and only pure additions
    #: are kept.
    max_delete_share: float = 0.25
    #: Deletions always permitted regardless of share. Without a floor the cap
    #: is strictest exactly where it should be loosest: on a short unit a single
    #: legitimate correction is already a large fraction of the graph.
    min_deletes: int = 5
    #: Whether a REPLACE may delete statements about one subject while writing
    #: about another. That is a rename, not a correction, and it leaves the new
    #: subject untyped and unlabelled while orphaning the old one.
    allow_subject_rename: bool = False

Attributes

allow_subject_rename = False class-attribute instance-attribute
max_delete_share = 0.25 class-attribute instance-attribute
min_deletes = 5 class-attribute instance-attribute

Methods:

__init__(max_delete_share=0.25, min_deletes=5, allow_subject_rename=False)

FixPatch dataclass

One kept fix as its own delete-then-insert patch.

The loop applies these one at a time and judges each on its own, so a fix that leaves the unit worse is undone alone instead of taking the whole pass with it -- which is how a critique used to lose its good corrections to one bad one.

Source code in ontocast/tool/facts_validation/critic_patch.py
@dataclass(frozen=True)
class FixPatch:
    """One kept fix as its own delete-then-insert patch.

    The loop applies these one at a time and judges each on its own, so a fix
    that leaves the unit worse is undone alone instead of taking the whole
    pass with it -- which is how a critique used to lose its good corrections
    to one bad one.
    """

    fix: TripleFix
    update: GraphUpdate
    deletes: int = 0
    inserts: int = 0

    @property
    def insert_triples(self) -> list[Triple]:
        """The statements this patch declares it writes.

        Not the same as what applying it adds to the graph. A REPLACE
        re-states the whole corrected node, so the parts that were already
        right are written as no-ops and the graph diff shows only the delete.
        The rollback test has to judge "wrote nothing" against what the fix
        meant to write, or it undoes corrections whose replacement the graph
        already held.
        """
        return [
            triple
            for operation in self.update.triple_operations
            if operation.type == "insert"
            for triple in operation.graph
        ]

Attributes

deletes = 0 class-attribute instance-attribute
fix instance-attribute
insert_triples property

The statements this patch declares it writes.

Not the same as what applying it adds to the graph. A REPLACE re-states the whole corrected node, so the parts that were already right are written as no-ops and the graph diff shows only the delete. The rollback test has to judge "wrote nothing" against what the fix meant to write, or it undoes corrections whose replacement the graph already held.

inserts = 0 class-attribute instance-attribute
update instance-attribute

Methods:

__init__(fix, update, deletes=0, inserts=0)

Functions:

apply_compiled_patch(graph, update)

Apply a compiled patch to graph in place, deletes before inserts.

The ordering is the one :meth:GraphUpdateRenderReport.to_graph_update fixes, and the operations are the same TripleOps a render produces -- this is the render's apply step over an in-memory graph, not a second way to mutate one.

Parameters:

Name Type Description Default
graph RDFGraph

The unit graph to patch.

required
update GraphUpdate

The compiled patch.

required
Source code in ontocast/tool/facts_validation/critic_patch.py
def apply_compiled_patch(graph: RDFGraph, update: GraphUpdate) -> None:
    """Apply a compiled patch to ``graph`` in place, deletes before inserts.

    The ordering is the one :meth:`GraphUpdateRenderReport.to_graph_update`
    fixes, and the operations are the same ``TripleOp``s a render produces --
    this is the render's apply step over an in-memory graph, not a second way
    to mutate one.

    Args:
        graph: The unit graph to patch.
        update: The compiled patch.
    """
    for operation in update.triple_operations:
        if operation.type == "delete":
            for triple in operation.graph:
                graph.remove(triple)
        else:
            for triple in operation.graph:
                graph.add(triple)

compile_critic_fixes(fixes, graph, *, index=None, policy=None)

Split a critique into a mechanical patch and the fixes needing a render.

Parameters:

Name Type Description Default
fixes Sequence[TripleFix]

Fixes from the critique report, in the order proposed.

required
graph RDFGraph

The rendered unit graph the fixes refer to.

required
index TripleIndex | None

The ids handed to the critic for this graph. When present, a fix that cites ids is resolved by lookup; the requoting path is the fallback for a fix that cites none.

None
policy CriticPatchPolicy | None

Limits on what the patch may destroy. None uses the defaults, which are the facts-side ones.

None

Returns:

Name Type Description
CompiledFixes CompiledFixes

update is delete-then-insert over the fixes in

CompiledFixes

applied; residual holds what needs judgement, noop what asks

CompiledFixes

for nothing.

Source code in ontocast/tool/facts_validation/critic_patch.py
def compile_critic_fixes(
    fixes: Sequence[TripleFix],
    graph: RDFGraph,
    *,
    index: TripleIndex | None = None,
    policy: CriticPatchPolicy | None = None,
) -> CompiledFixes:
    """Split a critique into a mechanical patch and the fixes needing a render.

    Args:
        fixes: Fixes from the critique report, in the order proposed.
        graph: The rendered unit graph the fixes refer to.
        index: The ids handed to the critic for this graph. When present, a fix
            that cites ids is resolved by lookup; the requoting path is the
            fallback for a fix that cites none.
        policy: Limits on what the patch may destroy. ``None`` uses the
            defaults, which are the facts-side ones.

    Returns:
        CompiledFixes: ``update`` is delete-then-insert over the fixes in
        ``applied``; ``residual`` holds what needs judgement, ``noop`` what asks
        for nothing.
    """
    active = policy if policy is not None else CriticPatchPolicy()
    candidates: list[_Candidate] = []
    residual: list[TripleFix] = []
    noop: list[TripleFix] = []
    bad_index_refs = 0
    unresolved_prefix = 0
    junk_refused = 0
    quarantined_literal = 0

    for fix in fixes:
        parsed_correct = _parse_payload(fix.correct_value, graph)
        correct = parsed_correct.graph
        # A payload that named an unexpandable prefix is counted once per fix,
        # and only when the fix needed that payload: a REMOVE cited by id is
        # carried out whatever its unused correct_value says.
        unresolved = parsed_correct.unresolved_prefix and fix.action != "REMOVE"
        # A payload holding an invalid typed literal yields no graph, so an
        # ADD or REPLACE built on it has nothing to insert and falls through
        # to residual below. Counted here, once, where the reason is still
        # known -- and, as with an unresolved prefix, only when the fix needed
        # the payload at all.
        if parsed_correct.quarantined_literal and fix.action != "REMOVE":
            logger.info(
                "Critic fix refused (quarantined_literal): %s", fix.correct_value
            )
            quarantined_literal += 1
        insert_triples = (
            [triple for triple in correct if triple not in graph] if correct else []
        )
        if fix.action in ("ADD", "REPLACE") and insert_triples:
            junk = _junk_reason(insert_triples, graph)
            if junk is not None:
                # Whole fix back, delete half included: carrying out the
                # removal alone would be the delete-only edit the rollback
                # exists to undo.
                logger.info("Critic fix refused (%s): %s", junk, fix.correct_value)
                junk_refused += 1
                residual.append(fix)
                continue

        matched: list[Triple] = []
        if fix.action in ("REMOVE", "REPLACE"):
            if index is not None and fix.triple_ids:
                by_id, bad = _deletes_by_id(fix, graph, index)
                bad_index_refs += bad
                if by_id is None:
                    unresolved_prefix += int(unresolved)
                    residual.append(fix)
                    continue
                matched = by_id
            else:
                # Fallback for a fix that cites no id. The quoted statements must
                # all be present: a misquote has misunderstood the graph, and
                # acting on it would delete something the critic never looked at.
                parsed_incorrect = _parse_payload(fix.incorrect_value, graph)
                incorrect = parsed_incorrect.graph
                unresolved = unresolved or parsed_incorrect.unresolved_prefix
                quoted = (
                    [triple for triple in incorrect if triple in graph]
                    if incorrect
                    else []
                )
                if not quoted or len(quoted) != len(incorrect or []):
                    unresolved_prefix += int(unresolved)
                    residual.append(fix)
                    continue
                matched = quoted

        # A fix that removes exactly what it re-adds changes nothing. Left in
        # `residual` it would ask the next pass to redo nothing; applied, it
        # would inflate the count of fixes that "landed".
        if matched and set(matched) == set(correct or []):
            noop.append(fix)
            continue

        if fix.action == "REMOVE":
            if not matched:
                residual.append(fix)
                continue
            candidates.append(_Candidate(fix=fix, deletes=matched))
        elif fix.action == "ADD":
            if not insert_triples:
                unresolved_prefix += int(unresolved)
                residual.append(fix)
                continue
            candidates.append(_Candidate(fix=fix, inserts=insert_triples))
        elif fix.action == "REPLACE":
            if not matched or not correct:
                unresolved_prefix += int(unresolved)
                residual.append(fix)
                continue
            candidates.append(
                _Candidate(fix=fix, deletes=matched, inserts=list(correct))
            )
        else:
            residual.append(fix)

    kept, screened_out, refused, capped = _screen(candidates, graph, active)
    residual.extend(screened_out)

    deletes = RDFGraph()
    inserts = RDFGraph()
    applied: list[TripleFix] = []
    patches: list[FixPatch] = []
    for candidate in kept:
        if not candidate.deletes and not candidate.inserts:
            residual.append(candidate.fix)
            continue
        fix_deletes = RDFGraph()
        fix_inserts = RDFGraph()
        for triple in candidate.deletes:
            fix_deletes.add(triple)
        for triple in candidate.inserts:
            fix_inserts.add(triple)
        # Delete-then-insert leaves a triple on both sides present either way,
        # so dropping it from the delete side changes nothing and keeps the
        # patch honest about what it removes.
        for triple in fix_inserts:
            fix_deletes.remove(triple)
        operations: list[TripleOp] = []
        if len(fix_deletes):
            operations.append(TripleOp(type="delete", graph=fix_deletes))
        if len(fix_inserts):
            operations.append(TripleOp(type="insert", graph=fix_inserts))
        if not operations:
            noop.append(candidate.fix)
            continue
        patches.append(
            FixPatch(
                fix=candidate.fix,
                update=GraphUpdate(triple_operations=operations),
                deletes=len(fix_deletes),
                inserts=len(fix_inserts),
            )
        )
        applied.append(candidate.fix)
        for triple in fix_deletes:
            deletes.add(triple)
        for triple in fix_inserts:
            inserts.add(triple)

    for triple in inserts:
        deletes.remove(triple)

    if not patches:
        return CompiledFixes(
            residual=residual,
            noop=noop,
            bad_index_refs=bad_index_refs,
            delete_capped=capped,
            deletes_refused=refused,
            unresolved_prefix=unresolved_prefix,
            junk_refused=junk_refused,
            quarantined_literal=quarantined_literal,
        )

    combined: list[TripleOp] = []
    if len(deletes):
        combined.append(TripleOp(type="delete", graph=deletes))
    if len(inserts):
        combined.append(TripleOp(type="insert", graph=inserts))
    logger.info(
        "Critic fixes: %d compiled to patches (-%d/+%d triples), "
        "%d need a render, %d asked for no change, %d delete half(s) refused, "
        "%d refused as placeholders, %d with an unresolved prefix, "
        "%d with a quarantined literal%s",
        len(applied),
        len(deletes),
        len(inserts),
        len(residual),
        len(noop),
        refused,
        junk_refused,
        unresolved_prefix,
        quarantined_literal,
        " (delete-share cap fired)" if capped else "",
    )
    return CompiledFixes(
        update=GraphUpdate(triple_operations=combined),
        patches=patches,
        applied=applied,
        residual=residual,
        noop=noop,
        bad_index_refs=bad_index_refs,
        delete_capped=capped,
        deletes_refused=refused,
        unresolved_prefix=unresolved_prefix,
        junk_refused=junk_refused,
        quarantined_literal=quarantined_literal,
    )