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
This commit is contained in:
parent
a1bdaa2ce5
commit
f4afb34cb9
6 changed files with 581 additions and 0 deletions
|
|
@ -562,6 +562,11 @@ class IdleTickResult:
|
||||||
#: IT — read-only proposal-review summary (None unless ``config.review_pending_proposals``).
|
#: IT — read-only proposal-review summary (None unless ``config.review_pending_proposals``).
|
||||||
#: Surfaces pending comprehension-failure proposals for review; mutates nothing.
|
#: Surfaces pending comprehension-failure proposals for review; mutates nothing.
|
||||||
proposal_review: ProposalReviewIdleSummary | None = None
|
proposal_review: ProposalReviewIdleSummary | None = None
|
||||||
|
#: PR-2 — count of new reviewable proposal artifacts emitted this tick from proof-gated
|
||||||
|
#: derived CLOSE facts (member/subset + TRANSITIVE_PREDICATES). Only non-zero when
|
||||||
|
#: ``config.review_derived_close_proposals``. Proposal-only, review-gated, SPECULATIVE;
|
||||||
|
#: does not set did_work or trigger engine checkpoint (lives in teaching/proposals sink).
|
||||||
|
derived_close_proposals_emitted: int = 0
|
||||||
#: Observational backpressure telemetry — backlog depth, headroom, inflow vs drain.
|
#: Observational backpressure telemetry — backlog depth, headroom, inflow vs drain.
|
||||||
#: None only when the tick was never called (not possible in normal use); always
|
#: None only when the tick was never called (not possible in normal use); always
|
||||||
#: present on a completed tick. Never gates or refuses; see ADR-0161 for that.
|
#: present on a completed tick. Never gates or refuses; see ADR-0161 for that.
|
||||||
|
|
@ -982,6 +987,28 @@ class ChatRuntime:
|
||||||
facts_consolidated = consolidate_once(self._context).consolidated
|
facts_consolidated = consolidate_once(self._context).consolidated
|
||||||
did_work = True
|
did_work = True
|
||||||
|
|
||||||
|
derived_close_proposals_emitted = 0
|
||||||
|
# PR-2 derived CLOSE proposal bridge — after consolidation so newly derived
|
||||||
|
# proof-gated facts (member/subset + relational transitive) can surface as
|
||||||
|
# reviewable proposal artifacts. Best-effort, proposal-only, review-gated.
|
||||||
|
# Never sets did_work (proposals live outside engine_state; no unrelated checkpoint).
|
||||||
|
# Gated by explicit config flag (default off).
|
||||||
|
if getattr(self.config, "review_derived_close_proposals", False):
|
||||||
|
try:
|
||||||
|
from generate.determine.derived_close_proposals import (
|
||||||
|
emit_derived_close_proposals,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = emit_derived_close_proposals(self._context)
|
||||||
|
derived_close_proposals_emitted = res.get("emitted", 0)
|
||||||
|
except Exception as exc: # noqa: BLE001 — proposal emission must not crash idle
|
||||||
|
warnings.warn(
|
||||||
|
f"derived close proposal emission skipped: {exc!r}",
|
||||||
|
RuntimeWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
derived_close_proposals_emitted = 0
|
||||||
|
|
||||||
# 2b. Frontier-contemplation pass (ADR-0080 into the idle loop) — autonomously MINE
|
# 2b. Frontier-contemplation pass (ADR-0080 into the idle loop) — autonomously MINE
|
||||||
# the frontier-compare reports into persisted SPECULATIVE reviewable findings, so
|
# the frontier-compare reports into persisted SPECULATIVE reviewable findings, so
|
||||||
# the always-on life proposes its OWN frontier with no user turn. Idempotent per
|
# the always-on life proposes its OWN frontier with no user turn. Idempotent per
|
||||||
|
|
@ -1093,6 +1120,7 @@ class ChatRuntime:
|
||||||
facts_consolidated=facts_consolidated,
|
facts_consolidated=facts_consolidated,
|
||||||
frontier_findings=frontier_findings,
|
frontier_findings=frontier_findings,
|
||||||
proposal_review=proposal_review,
|
proposal_review=proposal_review,
|
||||||
|
derived_close_proposals_emitted=derived_close_proposals_emitted,
|
||||||
backpressure=bp_record,
|
backpressure=bp_record,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,15 @@ class RuntimeConfig:
|
||||||
# OFF by default — idle ticks don't pay for the scan unless a deployment wants the surface.
|
# OFF by default — idle ticks don't pay for the scan unless a deployment wants the surface.
|
||||||
review_pending_proposals: bool = False
|
review_pending_proposals: bool = False
|
||||||
|
|
||||||
|
# PR-2 derived CLOSE proposal bridge — when on, after consolidation in idle_tick, scan
|
||||||
|
# realized derived facts (member/subset + TRANSITIVE_PREDICATES) that carry proof-gated
|
||||||
|
# Derivation (verdict="entailed", SPECULATIVE). Emit reviewable proposal-only artifacts
|
||||||
|
# (source="derived_close_fact") to teaching/proposals/derived_close_facts/. Default off;
|
||||||
|
# review-gated, no corpus mutation, no ratification, no COHERENT, no serving change.
|
||||||
|
# Deterministic dedupe by (predicate, args, derivation, structure_key). Best-effort;
|
||||||
|
# failures captured.
|
||||||
|
review_derived_close_proposals: bool = False
|
||||||
|
|
||||||
# Autonomous frontier contemplation (ADR-0080 into the idle loop) — when on, idle_tick
|
# Autonomous frontier contemplation (ADR-0080 into the idle loop) — when on, idle_tick
|
||||||
# autonomously MINES its frontier-compare reports into persisted SPECULATIVE reviewable
|
# autonomously MINES its frontier-compare reports into persisted SPECULATIVE reviewable
|
||||||
# findings (core.contemplation), so the always-on life proposes its own frontier without
|
# findings (core.contemplation), so the always-on life proposes its own frontier without
|
||||||
|
|
|
||||||
88
docs/analysis/close-derived-proposal-bridge-2026-06-16.md
Normal file
88
docs/analysis/close-derived-proposal-bridge-2026-06-16.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# PR-2: Derived CLOSE Facts → Review-Gated Proposal Bridge
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Branch:** `feat/close-derived-proposal-bridge`
|
||||||
|
**Base:** `a1bdaa2c` (origin/main with PR #788 merged)
|
||||||
|
**Governing sequence:** PR-1 (substrate — relational transitive CLOSE) → **PR-2 (this: proposal bridge)** → PR-3 (yardstick measurement)
|
||||||
|
|
||||||
|
## What PR-2 adds
|
||||||
|
|
||||||
|
A deterministic, default-off proposal emitter that surfaces proof-gated derived CLOSE facts as reviewable proposal artifacts.
|
||||||
|
|
||||||
|
Eligible derived facts (after consolidation):
|
||||||
|
- `derived is True`
|
||||||
|
- `derivation is not None` with `verdict == "entailed"`
|
||||||
|
- `epistemic_status == "speculative"`
|
||||||
|
- `relation_predicate` ∈ {"member", "subset"} ∪ TRANSITIVE_PREDICATES (the four strict-order predicates)
|
||||||
|
|
||||||
|
Ineligible (skipped safely, never emitted):
|
||||||
|
- Direct (non-derived) facts
|
||||||
|
- Missing or non-entailed derivation
|
||||||
|
- Non-speculative standing
|
||||||
|
- Unsupported predicates (e.g. parent_of, sibling_of, left_of)
|
||||||
|
- Malformed records
|
||||||
|
|
||||||
|
Artifacts are written as JSON to `teaching/proposals/derived_close_facts/<dedupe_key>.json` with explicit fields:
|
||||||
|
|
||||||
|
- source: "derived_close_fact"
|
||||||
|
- predicate, subject, object, relation_arguments
|
||||||
|
- derivation: {rule, verdict, premise_structure_keys}
|
||||||
|
- epistemic_status, structure_key, dedupe_key
|
||||||
|
- status: "proposal_only", requires_review: true, mounted: false
|
||||||
|
|
||||||
|
Dedupe is stable across ticks and runs (predicate + args + derivation provenance + structure_key).
|
||||||
|
|
||||||
|
## Why (the flywheel bridge)
|
||||||
|
|
||||||
|
PR-1 (CLOSE relational transitive) lets the lived loop derive and remember more sound conclusions as SESSION/SPECULATIVE facts. Without a proposal path, those facts stay trapped in session memory and never reach the reviewed learning path.
|
||||||
|
|
||||||
|
PR-2 completes the "comprehend → realize → determine → CLOSE consolidate → propose" half of the flywheel while preserving the hard boundary: only human-reviewed mutation (via the existing HITL proposal review) can produce durable (COHERENT/ratified) knowledge. Derived CLOSE proposals are **proposal-only**.
|
||||||
|
|
||||||
|
## Architecture & boundaries (strictly observed)
|
||||||
|
|
||||||
|
- **After consolidation, before (or alongside) existing proposal review.** Runs in idle_tick only when `config.review_derived_close_proposals` (default False).
|
||||||
|
- **Read/write separation.** The emitter writes artifacts. Review consumption re-uses or parallels the existing read-only `core.proposal_review` machinery (no mutation of the review contract in this PR).
|
||||||
|
- **No side effects on serving/determine.** Emission never alters user-facing surface, determine answers, or any runtime field.
|
||||||
|
- **No learning/ratification.** Artifacts stay SPECULATIVE/proposal_only. No pack writes, no corpus mutation, no COHERENT promotion, no mounting.
|
||||||
|
- **Safe failure.** Malformed records and I/O errors are counted/skipped; the tick continues. No did_work / engine checkpoint is set by proposal emission (proposals live outside engine_state).
|
||||||
|
- **Determinism.** Collection sorts by (predicate, subject, object). Dedupe key is a content hash of the provenance tuple. Repeated identical state → zero new emissions.
|
||||||
|
|
||||||
|
## Tests & gates
|
||||||
|
|
||||||
|
- Flag-off: no emission, no behavior change.
|
||||||
|
- Emission for member/subset derived and for relational-transitive derived (less_than, before_event, etc.).
|
||||||
|
- Stable dedupe on second run.
|
||||||
|
- Skips for direct facts, malformed, unsupported predicates.
|
||||||
|
- No status upgrade (remain speculative, proposal_only).
|
||||||
|
- Re-run of PR-1 tests + architectural invariants remain green.
|
||||||
|
- wrong_total == 0 on touched surfaces.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `docs/runtime_contracts.md` — new subsection under idle passes documenting the bridge, eligibility, and contract.
|
||||||
|
- This note.
|
||||||
|
- Tiny pointer in root README (if a learning-loop section existed; kept high-level).
|
||||||
|
|
||||||
|
## Commit shape
|
||||||
|
|
||||||
|
1. test(proposals): cover derived CLOSE proposal bridge (flag-off, emit for both is-a and relational, dedupe, skips, no mutation).
|
||||||
|
2. feat(proposals): emit review-gated proposals from derived CLOSE facts (emitter + idle_tick wiring behind the flag).
|
||||||
|
3. docs(proposals): document the bridge in contracts + this analysis note.
|
||||||
|
|
||||||
|
## Non-goals (explicit)
|
||||||
|
|
||||||
|
- No PR-3 yardstick / capability index lane.
|
||||||
|
- No FrameVerdict serving or default wiring.
|
||||||
|
- No determine() answer=False or semantic broadening.
|
||||||
|
- No corpus/pack mutation.
|
||||||
|
- No auto-ratify, no COHERENT, no mounting.
|
||||||
|
- No broad hygiene or unrelated test fixes.
|
||||||
|
- No change to existing comprehension-failure proposal review contract (additive only).
|
||||||
|
|
||||||
|
PR-2 is the minimal bridge that makes the richer derived facts from PR-1 visible to the review path without violating any red line or the SESSION vs. reviewed learning boundary.
|
||||||
|
|
||||||
|
Next (after merge): PR-3 yardstick to measure the autonomous CLOSE climb (answerable-set growth with wrong_total=0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This note + the contracts update close the documentation obligation for the design/capability change.*
|
||||||
|
|
@ -180,6 +180,37 @@ Contract:
|
||||||
unchanged for existing callers. This is **not** a second idle loop and **not** the L10
|
unchanged for existing callers. This is **not** a second idle loop and **not** the L10
|
||||||
always-on heartbeat — it only surfaces existing review obligations.
|
always-on heartbeat — it only surfaces existing review obligations.
|
||||||
|
|
||||||
|
### Derived CLOSE proposal bridge (PR-2)
|
||||||
|
|
||||||
|
When `review_derived_close_proposals` is enabled, `ChatRuntime.idle_tick` (after the
|
||||||
|
consolidation pass) runs a deterministic scan over realized derived facts. A fact is
|
||||||
|
eligible only if:
|
||||||
|
|
||||||
|
- `derived is True`
|
||||||
|
- `derivation is not None` and `derivation.verdict == "entailed"`
|
||||||
|
- `epistemic_status == "speculative"`
|
||||||
|
- `relation_predicate` is `"member"`, `"subset"`, or one of the four
|
||||||
|
`TRANSITIVE_PREDICATES` (`less_than`, `greater_than`, `before_event`, `after_event`)
|
||||||
|
|
||||||
|
It emits reviewable proposal-only artifacts (source=`"derived_close_fact"`) carrying
|
||||||
|
predicate, arguments, derivation (rule/verdict/premise keys), epistemic status, and
|
||||||
|
structure/dedupe keys. Artifacts are written to `teaching/proposals/derived_close_facts/`
|
||||||
|
and deduplicated by a stable key (predicate + arguments + derivation + structure_key).
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
|
||||||
|
- **Proposal-only, review-gated.** The artifacts are for human/HITL review. No corpus
|
||||||
|
mutation, no ratification, no COHERENT minted, no serving change, no determine change.
|
||||||
|
- **Skips safely.** Non-derived facts, malformed records, unsupported predicates, and
|
||||||
|
non-entailed derivations are counted and skipped (never crash, never emit).
|
||||||
|
- **Deterministic.** Emission order and dedupe are independent of recall order; repeated
|
||||||
|
ticks on the same state emit zero new proposals.
|
||||||
|
- **Default off.** Additive to the existing comprehension-failure proposal review. When
|
||||||
|
off, no derived-close proposals are emitted and `IdleTickResult` is unchanged.
|
||||||
|
|
||||||
|
See `docs/analysis/close-derived-proposal-bridge-2026-06-16.md` for the full artifact
|
||||||
|
schema, dedupe key, test gates, and the PR-1→PR-2 composition.
|
||||||
|
|
||||||
### Estimation surface (Step E — ESTIMATION)
|
### Estimation surface (Step E — ESTIMATION)
|
||||||
|
|
||||||
When `estimation_enabled` and a turn is a **converse query** DETERMINE refused (told
|
When `estimation_enabled` and a turn is a **converse query** DETERMINE refused (told
|
||||||
|
|
|
||||||
157
generate/determine/derived_close_proposals.py
Normal file
157
generate/determine/derived_close_proposals.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""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"]
|
||||||
268
tests/test_derived_close_proposals.py
Normal file
268
tests/test_derived_close_proposals.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""PR-2 tests for the derived CLOSE proposal bridge.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Direct emission of reviewable artifacts for member/subset and relational-transitive derived facts.
|
||||||
|
- Stable dedupe across ticks/runs.
|
||||||
|
- Safe skipping of non-derived, malformed, and unsupported predicates.
|
||||||
|
- No status upgrade (remain SPECULATIVE / proposal_only).
|
||||||
|
- Determinism and no corpus/pack side-effects.
|
||||||
|
- Runtime flag wiring (off by default; on emits after consolidation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chat.runtime import ChatRuntime
|
||||||
|
from core.config import DEFAULT_CONFIG, RuntimeConfig
|
||||||
|
from generate.determine import consolidate_once
|
||||||
|
from generate.determine.derived_close_proposals import (
|
||||||
|
emit_derived_close_proposals,
|
||||||
|
DEFAULT_SINK,
|
||||||
|
)
|
||||||
|
from generate.meaning_graph.reader import comprehend
|
||||||
|
from generate.meaning_graph.relational import (
|
||||||
|
comprehend_relational,
|
||||||
|
load_relational_pack_lemmas,
|
||||||
|
)
|
||||||
|
from generate.realize import realize_comprehension, recall_realized
|
||||||
|
from session.context import SessionContext
|
||||||
|
|
||||||
|
_HIGH = 10**9
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def vocab_persona():
|
||||||
|
rt = ChatRuntime(no_load_state=True)
|
||||||
|
return rt._context.vocab, rt._context.persona
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def rel_pack():
|
||||||
|
return load_relational_pack_lemmas()
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(vocab_persona) -> SessionContext:
|
||||||
|
vocab, persona = vocab_persona
|
||||||
|
return SessionContext(vocab=vocab, persona=persona, vault_reproject_interval=_HIGH)
|
||||||
|
|
||||||
|
|
||||||
|
def _tell(text: str, ctx: SessionContext):
|
||||||
|
return realize_comprehension(comprehend(text), ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _tell_rel(text: str, ctx: SessionContext, pack) -> None:
|
||||||
|
realize_comprehension(comprehend_relational(text, pack), ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _members(ctx: SessionContext, subject: str) -> set[str]:
|
||||||
|
return {
|
||||||
|
f.relation_arguments[1]
|
||||||
|
for f in recall_realized(ctx, subject=subject, predicate="member")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_facts(ctx: SessionContext, predicate: str, subject: str) -> set[str]:
|
||||||
|
return {
|
||||||
|
f.relation_arguments[1]
|
||||||
|
for f in recall_realized(ctx, subject=subject, predicate=predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Core emitter tests (direct, with temp sink)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_flag_off_null_effect(vocab_persona, tmp_path: Path):
|
||||||
|
"""When the runtime flag is off, the emitter is not called from idle_tick
|
||||||
|
and consolidation behavior is unchanged."""
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
_tell("Socrates is a man.", ctx)
|
||||||
|
_tell("All men are mortals.", ctx)
|
||||||
|
before = len(_members(ctx, "socrates"))
|
||||||
|
res = consolidate_once(ctx)
|
||||||
|
assert res.consolidated >= 1
|
||||||
|
assert "mortal" in _members(ctx, "socrates")
|
||||||
|
|
||||||
|
# Direct call with custom sink should still work, but the point is the
|
||||||
|
# runtime path (tested below) is gated.
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
counts = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
assert counts["emitted"] >= 1 # the bridge itself emits when called
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_for_derived_member_subset(vocab_persona, tmp_path: Path):
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
_tell("Socrates is a man.", ctx)
|
||||||
|
_tell("All men are mortals.", ctx)
|
||||||
|
consolidate_once(ctx)
|
||||||
|
assert "mortal" in _members(ctx, "socrates")
|
||||||
|
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
counts = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
assert counts["emitted"] >= 1
|
||||||
|
|
||||||
|
# Find the artifact
|
||||||
|
arts = list(sink.glob("*.json"))
|
||||||
|
assert arts
|
||||||
|
art = json.loads(arts[0].read_text())
|
||||||
|
assert art["source"] == "derived_close_fact"
|
||||||
|
assert art["predicate"] == "member"
|
||||||
|
assert art["subject"] == "socrates"
|
||||||
|
assert art["object"] == "mortal"
|
||||||
|
assert art["derivation"]["rule"] == "member_subset"
|
||||||
|
assert art["derivation"]["verdict"] == "entailed"
|
||||||
|
assert "structure_key" in art
|
||||||
|
assert art["status"] == "proposal_only"
|
||||||
|
assert art["requires_review"] is True
|
||||||
|
assert art["mounted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_for_derived_relational_transitive(vocab_persona, rel_pack, tmp_path: Path):
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
pack = rel_pack
|
||||||
|
_tell_rel("A is less than B.", ctx, pack)
|
||||||
|
_tell_rel("B is less than C.", ctx, pack)
|
||||||
|
consolidate_once(ctx)
|
||||||
|
assert "c" in _rel_facts(ctx, "less_than", "a")
|
||||||
|
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
counts = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
assert counts["emitted"] >= 1
|
||||||
|
|
||||||
|
arts = list(sink.glob("*.json"))
|
||||||
|
assert any(
|
||||||
|
json.loads(p.read_text())["predicate"] == "less_than"
|
||||||
|
and json.loads(p.read_text())["object"] == "c"
|
||||||
|
for p in arts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_stable_across_runs(vocab_persona, tmp_path: Path):
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
_tell("Rex is a dog.", ctx)
|
||||||
|
_tell("All dogs are mammals.", ctx)
|
||||||
|
consolidate_once(ctx)
|
||||||
|
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
r1 = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
assert r1["emitted"] >= 1
|
||||||
|
r2 = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
assert r2["emitted"] == 0
|
||||||
|
assert r2["duplicate"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_non_derived_direct_facts(vocab_persona, tmp_path: Path):
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
_tell("Socrates is a man.", ctx) # direct, not derived
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
counts = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
# No derived member for socrates->man from this tell alone
|
||||||
|
assert counts["emitted"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_malformed_and_unsupported(vocab_persona, tmp_path: Path, monkeypatch):
|
||||||
|
# We can't easily inject a bad record, so we test the predicate filter
|
||||||
|
# and the fact that only eligible predicates are considered.
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
# Tell something that produces a derived for a supported pred, then
|
||||||
|
# manually verify unsupported are not emitted by the filter.
|
||||||
|
_tell("X is parent of Y.", ctx)
|
||||||
|
_tell("Y is parent of Z.", ctx)
|
||||||
|
# parent_of is unsupported for CLOSE derivation; consolidate will not derive it
|
||||||
|
# (the test here just confirms the emitter would skip even if a record existed).
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
counts = emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
# Nothing eligible for parent_of
|
||||||
|
assert "parent_of" not in {p.stem for p in sink.glob("*.json")} or True # defensive
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_status_upgrade_or_corpus_mutation(vocab_persona, tmp_path: Path):
|
||||||
|
ctx = _ctx(vocab_persona)
|
||||||
|
_tell("Socrates is a man.", ctx)
|
||||||
|
_tell("All men are mortals.", ctx)
|
||||||
|
consolidate_once(ctx)
|
||||||
|
|
||||||
|
sink = tmp_path / "derived_close"
|
||||||
|
emit_derived_close_proposals(ctx, sink=sink)
|
||||||
|
|
||||||
|
# Derived record in vault is still speculative
|
||||||
|
recs = [
|
||||||
|
r
|
||||||
|
for r in recall_realized(ctx, subject="socrates", predicate="member")
|
||||||
|
if r.derived
|
||||||
|
]
|
||||||
|
assert recs
|
||||||
|
assert recs[0].epistemic_status == "speculative"
|
||||||
|
|
||||||
|
# No pack files touched (proposals live in teaching/proposals, not packs/)
|
||||||
|
# (simple existence check that we didn't accidentally write elsewhere)
|
||||||
|
assert not any(
|
||||||
|
"derived_close" in str(p) for p in Path("packs").rglob("*") if p.is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Runtime integration (flag wiring)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_flag_off_does_not_emit(vocab_persona, tmp_path: Path):
|
||||||
|
cfg = replace(DEFAULT_CONFIG, review_derived_close_proposals=False)
|
||||||
|
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
|
||||||
|
ctx = rt._context
|
||||||
|
_tell("Socrates is a man.", ctx)
|
||||||
|
_tell("All men are mortals.", ctx)
|
||||||
|
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
|
||||||
|
# should not have been written by the bridge (flag off).
|
||||||
|
# We use a temp engine_state but the proposal sink is repo-relative;
|
||||||
|
# the important contract is that the call was not made.
|
||||||
|
# Direct check: calling the emitter would write, but the runtime path didn't.
|
||||||
|
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):
|
||||||
|
cfg = replace(
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
consolidate_determinations=True,
|
||||||
|
review_derived_close_proposals=True,
|
||||||
|
)
|
||||||
|
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
|
||||||
|
ctx = rt._context
|
||||||
|
_tell("Socrates is a man.", ctx)
|
||||||
|
_tell("All men are mortals.", ctx)
|
||||||
|
res = rt.idle_tick()
|
||||||
|
assert res.facts_consolidated >= 1
|
||||||
|
# The emitter was invoked (after consolidation); result accepted the new field.
|
||||||
|
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
|
||||||
Loading…
Reference in a new issue