feat(pre-push-safety-net): scope comparison completeness and ambiguity (M1b 10.2, 10.3)

Two vetoes M1a recorded were global where the evidence is local.

Comparison required every selected base question to complete, so one unsupported
family anywhere prevented an otherwise complete target comparison. Completeness
is now demanded over the required scope: the counterpart question plus the
dependencies the change context actually recorded for it. Every other gap stays
in aggregate coverage and is still reported, never discharged. An incomplete
counterpart, an incomplete recorded dependency, an unknown-ownership boundary
and a truncated base scan all still yield unknown.

Correspondence ambiguity was recorded file-wide and inherited by every question,
so repeated unchanged lines in one file invalidated every question in the
repository and blocked the whole transaction comparison. It is now decided per
operation by the correspondence classifier: an operation needing an ambiguous
anchor stays uncomparable, while a uniquely mapped or newly introduced operation
in the same file is unaffected. The mapping is still withheld and still reported.

On the NodeGoat case the ambiguity is on the target's own file, so file-level
scoping would not have been enough; only per-operation resolution is.

1,347 tests passed, nine skipped. No capability enabled; rollout stays NO-GO.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-16 15:45:57 +02:00
co-authored by Claude Opus 5
parent 7e0d01d9e8
commit 8ff1d9a638
5 changed files with 182 additions and 23 deletions
+9 -1
View File
@@ -179,6 +179,9 @@ def _families(language: str, *, has_handler: bool) -> tuple[str, ...]:
return tuple(sorted(families))
AMBIGUOUS_CORRESPONDENCE = "ambiguous_line_correspondence"
def affected_context(
context: ChangeContext,
questions: Sequence[QuestionIdentity],
@@ -202,7 +205,12 @@ def affected_context(
gaps.append("configuration_dependency_projection_unavailable:contributor-scan")
if context.base_revision is None:
gaps.append("comparison_base_unavailable")
inherited_gap = bool(gaps)
# Line-correspondence ambiguity is not a context gap: it says which base lines a head
# line could map to, which only the later per-operation comparison consumes. Inheriting
# it here made one file with repeated unchanged lines invalidate every question in the
# repository. The mapping itself is still withheld, so an operation that needs an
# ambiguous anchor remains uncomparable.
inherited_gap = any(not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) for gap in gaps)
renamed = {context.decode_path(r.base_path): context.decode_path(r.head_path) for r in context.renames}
for identity in questions:
if execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic:
+43 -13
View File
@@ -17,7 +17,7 @@ from openultrasast.cpg.backend import CpgResult
from openultrasast.model.contracts import ChangeContext, ExecutionBudget, QuestionIdentity
from openultrasast.model.ladder import Rung
from openultrasast.model.pipeline import ModelFinding, _arbitrate_all
from openultrasast.model.regions import ScanRegion
from openultrasast.model.regions import AMBIGUOUS_CORRESPONDENCE, ScanRegion
from openultrasast.model.scan import ModelScanResult, ScanBudget, _prefetched, _spec_for, scan_repository
from openultrasast.model.specs import ConfigSpec, DominanceSpec, TaintSpec
from openultrasast.push.cache import ArtifactCache, SemanticKeys
@@ -211,7 +211,7 @@ def _valid_answer(scan: ModelScanResult, question: QuestionIdentity, operations:
return len(rows) == sum(op.question == question for op in operations)
def _context_boundaries(scan: ModelScanResult, question: QuestionIdentity | None = None) -> tuple[str, ...]:
def _context_boundaries(scan: ModelScanResult, *questions: QuestionIdentity) -> tuple[str, ...]:
# Aggregate coverage keeps every gap. A candidate may ignore only a recognized
# question-owned boundary attached to a different, recorded question. Unknown
# ownership and graph/declaration gaps remain global.
@@ -222,16 +222,43 @@ def _context_boundaries(scan: ModelScanResult, question: QuestionIdentity | None
"context_scope_empty:contributor-scan:",
"dynamic_external_or_depth_context_unresolved:contributor-scan:",
)
others = {q.identity.question_id for q in scan.scope.selected if q.identity != question}
others.update(q.identity.question_id for q in scan.scope.deferred if q.identity != question)
retained = set(questions)
others = {q.identity.question_id for q in scan.scope.selected if q.identity not in retained}
others.update(q.identity.question_id for q in scan.scope.deferred if q.identity not in retained)
return tuple(
boundary
for boundary in scan.scope.unresolved_boundaries
if not boundary.startswith("evidence_unknown:")
and not (question is not None and boundary.startswith(owned_prefixes) and boundary.rsplit(":", 1)[-1] in others)
and not (questions and boundary.startswith(owned_prefixes) and boundary.rsplit(":", 1)[-1] in others)
)
def _blocking_context(context: ChangeContext) -> tuple[str, ...]:
"""Transaction context gaps that block any comparison.
Correspondence ambiguity is excluded because it is now decided per operation: an
operation needing an ambiguous anchor is reported `ambiguous` and stays uncomparable,
while a uniquely mapped or newly introduced operation elsewhere in the same file is
unaffected. Every other gap still blocks and is still reported.
"""
return tuple(gap for gap in context.unresolved_boundaries if not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE))
def _required_scope(question: QuestionIdentity, context: ChangeContext) -> set[QuestionIdentity]:
"""Base questions this comparison requires: the counterpart and its recorded dependencies.
Completeness is demanded over exactly this scope. Every other gap stays in aggregate
coverage and is still reported; it is never discharged. An unrelated unsupported family
no longer prevents an otherwise complete target comparison, and unknown dependency
ownership remains inside the scope because it is not attributed to another question.
"""
required = {_counterpart(question, context)}
for relation in context.relationships:
if relation.target == question and relation.evidence:
required.add(_counterpart(relation.source, context))
return required
def _counterpart(question: QuestionIdentity, context: ChangeContext) -> QuestionIdentity:
renamed = {context.decode_path(r.head_path): context.decode_path(r.base_path) for r in context.renames}
return replace(question, path=renamed.get(question.path, question.path))
@@ -287,7 +314,7 @@ def compare_evidence(
and execution_budget is not None
and time.monotonic() < execution_budget.deadline_monotonic
and context.base_revision is not None
and not context.unresolved_boundaries
and not _blocking_context(context)
and head_semantics == base_semantics
and complete(head)
and complete(base)
@@ -387,24 +414,27 @@ def _compare_evidence(
identical = tuple(b for b in contextual if b.operation == op.operation)
change = _change_evidence(op, context)
reason = "base_incomplete"
required = _required_scope(op.question, context)
selected = {q.identity for q in base.scope.selected} if base is not None and base.scope is not None else set()
answered = {q.identity for q in base.question_outcomes if q.status == "completed"} if base is not None else set()
complete = bool(
base
and base.scope
and base.scope.population_complete
# A truncated base scan cannot establish absence, wherever it was truncated.
and all(question.reason == "tier_zero" for question in base.scope.deferred)
and not _context_boundaries(base)
and not _context_boundaries(base, *required)
and not base.degradations
and _valid_answer(base, counterpart, base_ops)
and counterpart in {q.identity for q in base.scope.selected}
and counterpart in {q.identity for q in base.question_outcomes if q.status == "completed"}
and {q.identity for q in base.scope.selected} == {q.identity for q in base.question_outcomes}
and all(q.status == "completed" for q in base.question_outcomes)
and required <= selected
and required <= answered
and {q.identity for q in base.question_outcomes} <= selected
and all(_valid_answer(base, question, base_ops) for question in required)
)
if head_semantics != base_semantics:
reason = "semantics_mismatch"
elif context.base_revision is None:
reason = "base_unavailable"
elif context.unresolved_boundaries:
elif _blocking_context(context):
reason = "change_context_unresolved"
elif (
not head.scope