core/generate/determine/derived_close_proposals.py
Shay f4afb34cb9
feat(proposals): bridge derived CLOSE facts into review proposals (#789)
* test(proposals): cover derived CLOSE proposal bridge

- Flag-off and emission for member/subset + relational transitive derived facts.
- Stable dedupe, skips for non-derived/malformed/unsupported.
- No status upgrade, no corpus side-effects.
- Runtime flag wiring smoke (consolidate + bridge on → emits after idle_tick).
- Re-runs of PR-1 tests + invariants stay green.

* feat(proposals): emit review-gated proposals from derived CLOSE facts

- New focused emitter generate/determine/derived_close_proposals.py:
  - collect eligible derived (member/subset + TRANSITIVE) with entailed Derivation + SPECULATIVE.
  - deterministic sort + stable dedupe key (predicate+args+derivation+structure_key).
  - write proposal-only artifacts (source=derived_close_fact) to dedicated sink.
  - best-effort, counts returned, safe skips.
- RuntimeConfig: review_derived_close_proposals (default False), documented.
- ChatRuntime.idle_tick: after consolidation (when flag on), call emitter best-effort (no did_work, no engine checkpoint).
- IdleTickResult: new field derived_close_proposals_emitted (additive, documented).
- No change to existing proposal_review contract or comprehension-failure sink.
- No serving/determine side-effects.

* docs(proposals): document derived CLOSE proposal bridge

- docs/runtime_contracts.md: new subsection under idle passes describing eligibility,
  contract (proposal-only, review-gated, default-off, no corpus/COHERENT/serving change),
  and cross-ref to the analysis note.
- New dated analysis note with why (flywheel bridge after PR-1), architecture/boundaries,
  artifact schema, dedupe, tests, non-goals, and composition with PR-1/PR-3.
- Tiny high-level pointer not added (no suitable learning-loop section in README at this granularity).

* fix(proposals): harden derived CLOSE proposal emission
2026-06-16 15:39:46 -07:00

157 lines
No EOL
5.3 KiB
Python

"""PR-2: deterministic proposal bridge from proof-gated derived CLOSE facts.
Collects realized derived facts (member/subset + TRANSITIVE_PREDICATES) that carry
a Derivation with verdict="entailed" and are SPECULATIVE. Emits reviewable proposal-only
artifacts (source="derived_close_fact") to a dedicated sink.
- Default-off via RuntimeConfig.review_derived_close_proposals
- Review-gated, proposal-only (never ratification or corpus mutation)
- Deterministic dedupe by stable key (predicate + args + derivation + structure_key)
- Skips non-derived, malformed, unsupported, non-entailed safely
- Best-effort; failures do not corrupt state or set did_work on engine checkpoint
Intended to be called from idle_tick *after* consolidation (Step D) so that newly
derived facts can immediately surface as proposals.
"""
from __future__ import annotations
import json
from hashlib import sha256
from pathlib import Path
from typing import Any
from generate.meaning_graph.relational import TRANSITIVE_PREDICATES
from generate.realize import RealizedRecord, recall_realized
#: Dedicated sink for derived CLOSE proposals (distinct from comprehension-failures
#: to keep families clean while still reviewable by the same HITL tooling).
DEFAULT_SINK = (
Path(__file__).resolve().parents[3]
/ "teaching"
/ "proposals"
/ "derived_close_facts"
)
def _stable_dedupe_key(rec: RealizedRecord) -> str:
"""Stable dedupe key for a derived record. Includes derivation provenance so that
a re-derived fact with different proof chain is treated as distinct if premises differ."""
if rec.derivation is None:
return ""
key = (
rec.relation_predicate,
rec.relation_arguments[0] if len(rec.relation_arguments) > 0 else "",
rec.relation_arguments[1] if len(rec.relation_arguments) > 1 else "",
rec.derivation.rule,
tuple(rec.derivation.premise_structure_keys),
rec.structure_key,
)
return sha256(repr(key).encode("utf-8")).hexdigest()
def _is_eligible(rec: RealizedRecord) -> bool:
if not rec.derived or rec.derivation is None:
return False
if rec.derivation.verdict != "entailed":
return False
if getattr(rec, "epistemic_status", None) != "speculative":
return False
p = rec.relation_predicate
if p not in {"member", "subset"} and p not in TRANSITIVE_PREDICATES:
return False
if len(getattr(rec, "relation_arguments", ())) != 2:
return False
return True
def emit_derived_close_proposals(
ctx: "SessionContext",
*,
sink: Path | None = None,
max_emissions: int = 100,
) -> dict[str, int]:
"""Scan realized facts for eligible derived CLOSE conclusions and emit proposal artifacts.
Returns counts:
considered, eligible, emitted, duplicate, skipped
Artifacts are written only for first sighting (deduped by stable key).
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.mkdir(parents=True, exist_ok=True)
considered = 0
eligible = 0
emitted = 0
duplicate = 0
skipped = 0
candidates: list[tuple[str, str, str, RealizedRecord]] = []
# member and subset first (from PR-1 is-a), then the relational transitive
for p in ["member", "subset"] + sorted(TRANSITIVE_PREDICATES):
for rec in recall_realized(ctx, predicate=p):
considered += 1
if _is_eligible(rec):
eligible += 1
candidates.append(
(p, rec.relation_arguments[0], rec.relation_arguments[1], rec)
)
else:
skipped += 1
# deterministic global order
candidates.sort(key=lambda t: (t[0], t[1], t[2]))
for p, subj, obj, rec in candidates:
if emitted >= max_emissions:
break
dkey = _stable_dedupe_key(rec)
path = sink / f"{dkey}.json"
if path.exists():
duplicate += 1
continue
try:
artifact: dict[str, Any] = {
"source": "derived_close_fact",
"predicate": rec.relation_predicate,
"subject": subj,
"object": obj,
"relation_arguments": list(rec.relation_arguments),
"derivation": {
"rule": rec.derivation.rule,
"verdict": rec.derivation.verdict,
"premise_structure_keys": list(rec.derivation.premise_structure_keys),
},
"epistemic_status": rec.epistemic_status,
"structure_key": rec.structure_key,
"dedupe_key": dkey,
"status": "proposal_only",
"requires_review": True,
"mounted": False,
}
path.write_text(
json.dumps(artifact, indent=2, sort_keys=True), encoding="utf-8"
)
emitted += 1
except Exception:
skipped += 1
continue
return {
"considered": considered,
"eligible": eligible,
"emitted": emitted,
"duplicate": duplicate,
"skipped": skipped,
}
__all__ = ["emit_derived_close_proposals", "DEFAULT_SINK"]