fix(pre-push-safety-net): bind dependency gaps to the candidate (M1b 10.7)

The runner handed every candidate the whole transaction's coverage reason list as
its unresolved dependencies, so admission always answered dependency_unresolved.
That list even included other candidates' comparison outcomes, so one unresolved
comparison anywhere marked every other claim as having an unresolved dependency.

Gaps are now attributed. A boundary carrying a question id counts only when that
question is in this candidate's required comparison scope, the same scope 10.2
established. A gap nothing attributed to any question always counts, because
unknown ownership is not evidence of independence. Integrity degradations count.

A declared exclusion, a correspondence ambiguity and a bounded reachability do
not, because each was already weighed against this specific claim: the first is a
scope choice, the second is decided per operation, and the third is decided per
claim by the comparison.

Found by the M1b production rerun rather than by the suite: the comparison reached
a correct new verdict and admission still rejected it for a dependency belonging
to unrelated questions. It is not an M1b exit blocker, since eligibility stays
disabled, but it would have blocked admission forever once capabilities qualify.

1,371 tests passed, nine skipped. 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 18:52:04 +02:00
co-authored by Claude Opus 5
parent b8b46f1c19
commit 9a67f54ea1
4 changed files with 95 additions and 2 deletions
+2 -1
View File
@@ -316,7 +316,7 @@ Preserve all completed task evidence below.
- _Depends: 10.5_
- _Requirements: 2.2, 2.3, 8.2, 8.4_
- _Verified 2026-09-16:_ 1,365 tests passed, nine skipped; Ruff/format/mypy passed. Graph boundaries are split into integrity failures, which still demote a completed answer, and declared exclusions, which do not. Whether a particular answer needed the excluded semantics is carried by that question's own unresolved call destinations from 10.5, not assumed for every answer. Controls exercise the real partition logic rather than an injected reason: a vendored directory leaves the answer completed with the exclusion still reported, an unresolved symlink still demotes, and the two together still demote. The exclusion stays physical and out of targets and graphs. The NodeGoat rerun showed the same distinction was still missing inside comparison, where any degradation and any boundary blocked: comparison now filters declared exclusions and correspondence ambiguity on both sides, keeping integrity degradations and every other boundary blocking.
- [ ] 10.7 Bind candidate dependency gaps to the candidate
- [x] 10.7 Bind candidate dependency gaps to the candidate
- **Discovered 2026-09-16 in the M1b production rerun.** The runner passes every coverage reason as every candidate's `dependency_gaps`, so admission always answers `dependency_unresolved`. That is not "an unresolved dependency that undermines the claim"; it is every gap anywhere in the transaction.
- Supply the gaps that pertain to this candidate's required comparison scope, the same scope 10.2 established. Unknown ownership still counts against the candidate.
- Not an M1b exit blocker, since eligibility stays disabled and no alert is emitted either way. It would prevent admission from ever passing after qualification, so it belongs before M4.
@@ -324,6 +324,7 @@ Preserve all completed task evidence below.
- _Boundary: Push runner, Delta and Actionability Policy_
- _Depends: 10.2_
- _Requirements: 3.1, 3.3, 3.4_
- _Verified 2026-09-16:_ 1,371 tests passed, nine skipped; Ruff/format/mypy passed. Gaps are now attributed: a boundary carrying a question id counts only when that question is in this candidate's required scope, an unattributed gap always counts because unknown ownership is not evidence of independence, and integrity degradations count. A declared exclusion, correspondence ambiguity and bounded reachability do not, because each was already weighed against this specific claim. The runner no longer hands every candidate the whole transaction's reason list, which had included other candidates' comparison outcomes.
- [ ] 10.8 Carry query-to-operation provenance into the reported evidence
- The target is selected through a file-fallback question with `function=None` while rows report method `<lambda>2`. M1a showed this did not block the comparison once the question completed, so it is provenance quality rather than the blocker.
- Record verified query-to-operation provenance on the evidence; do not inject benchmark function names into regions or count coincident sites.
+40
View File
@@ -330,6 +330,46 @@ def _blocking_degradations(scan: ModelScanResult) -> tuple[str, ...]:
return tuple(str(d.get("reason")) for d in scan.degradations if str(d.get("reason")) not in DECLARED_EXCLUSION_GAPS)
def _owning_question(gap: str) -> str | None:
"""The question id a gap is attributed to, or None when nothing attributed it."""
tail = gap.rsplit(":", 1)[-1]
return tail if len(tail) == 64 and all(character in "0123456789abcdef" for character in tail) else None
def dependency_gaps(
delta: CandidateDelta, *, context: ChangeContext, head: ModelScanResult, base: ModelScanResult | None = None
) -> tuple[str, ...]:
"""Unresolved dependencies of THIS claim, not every gap anywhere in the transaction.
A gap attributed to an unrelated question is another question's problem. A declared
exclusion is a scope choice, correspondence ambiguity is decided per operation, and bounded
reachability was already weighed against this specific claim by the comparison, so none of
them is an unresolved dependency here. Everything else in this candidate's required scope
counts, and so does a gap nothing attributed to any question, because unknown ownership is
not evidence of independence.
"""
op = delta.head_operation
if op is None:
return ()
required = {question.question_id for question in _required_scope(op.question, context)} | {op.question.question_id}
def pertains(gap: str) -> bool:
if gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) or gap.startswith(REACHABILITY_BOUNDED):
return False
if gap.split(":")[0] in DECLARED_EXCLUSION_GAPS:
return False
owner = _owning_question(gap)
return owner is None or owner in required
gaps = [gap for gap in context.unresolved_boundaries if pertains(gap)]
for scan in (head, base):
if scan is None:
continue
gaps.extend(gap for gap in (scan.scope.unresolved_boundaries if scan.scope else ()) if pertains(gap))
gaps.extend(_blocking_degradations(scan))
return tuple(dict.fromkeys(gaps))
def _required_scope(question: QuestionIdentity, context: ChangeContext) -> set[QuestionIdentity]:
"""Base questions this comparison requires: the counterpart and its recorded dependencies.
+2 -1
View File
@@ -36,6 +36,7 @@ from openultrasast.push.policy import (
admission_push_result,
admit_candidates,
compare_targeted_base,
dependency_gaps,
semantics_digest,
)
from openultrasast.push.report import PushReport, ReportDelivery, ScanRecord, _completed_scan, deliver_report, render_report
@@ -369,7 +370,7 @@ def _analyze(
delta.semantics,
),
comparison,
tuple(reasons),
dependency_gaps(c, context=context, head=head_scan, base=delta.base_scan),
)
for c in delta.candidates
)
+51
View File
@@ -549,3 +549,54 @@ def test_a_declared_exclusion_boundary_does_not_block_either():
base = scan(rows=inventory_rows(), findings=False)
base = replace(base, scope=replace(base.scope, unresolved_boundaries=("vendor_semantics_unresolved",)))
assert delta(scan(), base, changed_line_context()).novelty == "new"
def test_dependency_gaps_are_bound_to_the_candidate():
"""M1b 10.7: an unrelated question's gap is not this claim's unresolved dependency."""
from openultrasast.push.policy import dependency_gaps
head = scan()
mine = head.scope.selected[0].identity
other = replace(mine, family="path")
head = replace(
head,
scope=replace(
head.scope,
selected=(*head.scope.selected, RankedQuestion(other, 0.0, ())),
unresolved_boundaries=(
"context_projection_unavailable:contributor-scan:" + other.question_id,
"context_projection_unavailable:contributor-scan:" + mine.question_id,
"dynamic_external_or_depth_context_unresolved:contributor-scan:" + mine.question_id,
"vendor_semantics_unresolved",
"612e6a73:ambiguous_line_correspondence",
"snapshot:lfs_blob_unavailable",
),
),
question_outcomes=(*head.question_outcomes, QuestionOutcome(other, "unresolved", "unsupported_family", "[]")),
)
candidate = delta(head, scan(rows=inventory_rows(), findings=False), changed_line_context())
gaps = dependency_gaps(candidate, context=changed_line_context(), head=head)
assert "context_projection_unavailable:contributor-scan:" + mine.question_id in gaps
assert "snapshot:lfs_blob_unavailable" in gaps
# Another question's problem, a scope choice, a per-operation decision and a weighed bound.
assert "context_projection_unavailable:contributor-scan:" + other.question_id not in gaps
assert "vendor_semantics_unresolved" not in gaps
assert not any(g.endswith("ambiguous_line_correspondence") for g in gaps)
assert not any(g.startswith("dynamic_external_or_depth") for g in gaps)
def test_an_integrity_degradation_is_this_candidate_s_dependency():
from openultrasast.push.policy import dependency_gaps
head = degraded(scan(), "cpg_sharded", "vendor_semantics_unresolved")
candidate = delta(scan(), scan(rows=inventory_rows(), findings=False), changed_line_context())
gaps = dependency_gaps(candidate, context=changed_line_context(), head=head)
assert "cpg_sharded" in gaps and "vendor_semantics_unresolved" not in gaps
def test_a_candidate_without_an_operation_reports_no_dependency_gaps():
"""Witness resolution is its own admission reason; this one has nothing to attribute gaps to."""
from openultrasast.push.policy import dependency_gaps
candidate = replace(delta(scan(), scan(rows=inventory_rows(), findings=False), changed_line_context()), head_operation=None)
assert dependency_gaps(candidate, context=changed_line_context(), head=scan()) == ()