fix(proposals): harden derived CLOSE proposal emission

This commit is contained in:
Shay 2026-06-16 15:28:19 -07:00
parent e1907ee921
commit 3124b14ba6
2 changed files with 60 additions and 25 deletions

View file

@ -78,6 +78,10 @@ def emit_derived_close_proposals(
Artifacts are written only for first sighting (deduped by stable key). Artifacts are written only for first sighting (deduped by stable key).
Order is deterministic (sorted by (predicate, subject, object)). Order is deterministic (sorted by (predicate, subject, object)).
Metric invariant (post-collection, pre-write):
considered == eligible + skipped
Write failures (I/O etc.) also increment skipped (best-effort).
""" """
sink = sink or DEFAULT_SINK sink = sink or DEFAULT_SINK
sink.mkdir(parents=True, exist_ok=True) sink.mkdir(parents=True, exist_ok=True)
@ -99,6 +103,8 @@ def emit_derived_close_proposals(
candidates.append( candidates.append(
(p, rec.relation_arguments[0], rec.relation_arguments[1], rec) (p, rec.relation_arguments[0], rec.relation_arguments[1], rec)
) )
else:
skipped += 1
# deterministic global order # deterministic global order
candidates.sort(key=lambda t: (t[0], t[1], t[2])) candidates.sort(key=lambda t: (t[0], t[1], t[2]))
@ -112,28 +118,32 @@ def emit_derived_close_proposals(
duplicate += 1 duplicate += 1
continue continue
artifact: dict[str, Any] = { try:
"source": "derived_close_fact", artifact: dict[str, Any] = {
"predicate": rec.relation_predicate, "source": "derived_close_fact",
"subject": subj, "predicate": rec.relation_predicate,
"object": obj, "subject": subj,
"relation_arguments": list(rec.relation_arguments), "object": obj,
"derivation": { "relation_arguments": list(rec.relation_arguments),
"rule": rec.derivation.rule, "derivation": {
"verdict": rec.derivation.verdict, "rule": rec.derivation.rule,
"premise_structure_keys": list(rec.derivation.premise_structure_keys), "verdict": rec.derivation.verdict,
}, "premise_structure_keys": list(rec.derivation.premise_structure_keys),
"epistemic_status": rec.epistemic_status, },
"structure_key": rec.structure_key, "epistemic_status": rec.epistemic_status,
"dedupe_key": dkey, "structure_key": rec.structure_key,
"status": "proposal_only", "dedupe_key": dkey,
"requires_review": True, "status": "proposal_only",
"mounted": False, "requires_review": True,
} "mounted": False,
path.write_text( }
json.dumps(artifact, indent=2, sort_keys=True), encoding="utf-8" path.write_text(
) json.dumps(artifact, indent=2, sort_keys=True), encoding="utf-8"
emitted += 1 )
emitted += 1
except Exception:
skipped += 1
continue
return { return {
"considered": considered, "considered": considered,

View file

@ -218,13 +218,16 @@ def test_runtime_flag_off_does_not_emit(vocab_persona, tmp_path: Path):
ctx = rt._context ctx = rt._context
_tell("Socrates is a man.", ctx) _tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx) _tell("All men are mortals.", ctx)
rt.idle_tick() # consolidation happens (if flag on), proposal bridge must not res = rt.idle_tick() # consolidation happens (if flag on), proposal bridge must not
# Even if consolidation ran, the derived proposal sink under the test path # Even if consolidation ran, the derived proposal sink under the test path
# should not have been written by the bridge (flag off). # should not have been written by the bridge (flag off).
# We use a temp engine_state but the proposal sink is repo-relative; # We use a temp engine_state but the proposal sink is repo-relative;
# the important contract is that the call was not made. # the important contract is that the call was not made.
# Direct check: calling the emitter would write, but the runtime path didn't. # Direct check: calling the emitter would write, but the runtime path didn't.
assert True # behavioral contract covered by the flag test below assert res.derived_close_proposals_emitted == 0
# No files should have been created for this disabled path (best-effort check)
sink = Path("teaching") / "proposals" / "derived_close_facts"
assert not any(sink.glob("*.json")) or True # may have pre-existing from other tests; non-fatal
def test_runtime_flag_on_emits_after_consolidation(vocab_persona, tmp_path: Path): def test_runtime_flag_on_emits_after_consolidation(vocab_persona, tmp_path: Path):
@ -240,4 +243,26 @@ def test_runtime_flag_on_emits_after_consolidation(vocab_persona, tmp_path: Path
res = rt.idle_tick() res = rt.idle_tick()
assert res.facts_consolidated >= 1 assert res.facts_consolidated >= 1
# The emitter was invoked (after consolidation); result accepted the new field. # The emitter was invoked (after consolidation); result accepted the new field.
assert hasattr(res, "derived_close_proposals_emitted") assert hasattr(res, "derived_close_proposals_emitted")
def test_write_failure_increments_skipped(vocab_persona, tmp_path: Path, monkeypatch):
"""Verify that I/O failure on write_text is caught locally, increments skipped,
does not emit, and does not abort the emitter (best-effort per contract)."""
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
consolidate_once(ctx)
sink = tmp_path / "derived_close"
def always_fail_write(self, *a, **k):
raise OSError("simulated I/O failure for test")
monkeypatch.setattr(Path, "write_text", always_fail_write)
counts = emit_derived_close_proposals(ctx, sink=sink)
assert counts["emitted"] == 0
assert counts["skipped"] >= 1 # write failure counted here (plus any ineligible)
# sink should have no files created
assert len(list(sink.glob("*.json"))) == 0