From 577338bba913c2b5579424b1f48b0899eaac5645 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 16:04:55 +0000 Subject: [PATCH] docs(learn): document causal persistence contract Co-authored-by: Sara Loera --- docs/explanation/artifacts.mdx | 83 ++++++++++++++++++++++++++++++---- docs/reference/events.mdx | 49 +++++++++++++------- docs/reference/python-api.mdx | 64 ++++++++++++++++++++++++-- 3 files changed, 167 insertions(+), 29 deletions(-) diff --git a/docs/explanation/artifacts.mdx b/docs/explanation/artifacts.mdx index e27739f..2d896c5 100644 --- a/docs/explanation/artifacts.mdx +++ b/docs/explanation/artifacts.mdx @@ -412,15 +412,70 @@ JSON serialization uses `canonical_dumps()` with sorted keys for byte-identical ## learn.jsonl (optional) -Learning signals are stored separately when the learn phase emits proposals. +Learning proposals are stored separately from the event timeline. Each +non-empty line is a `learn/1.0` record ([JSON schema](/schema/learn/1.0.0.json)) +that links back to an existing event. ### Schema ```json -{"proposal_id": "prop_1", "type": "threshold", "target": "veto_confidence", "suggested_value": 0.8} -{"proposal_id": "prop_2", "type": "memory", "key": "pattern_detected", "value": "sql_injection_attempt"} +{ + "schema_version": "learn/1.0", + "id": "SafetyPolicy:ep_2024_abc123_s0", + "episode_id": "ep_2024_abc123_s0", + "agent_id": "SafetyPolicy", + "caused_by": "evt_terminate123", + "timestamp": "2024-01-15T10:30:05Z", + "payload": { + "id": "SafetyPolicy:ep_2024_abc123_s0", + "policy_id": "SafetyPolicy", + "basis": { + "success": 0, + "reasons": ["policy veto"], + "latencies": {}, + "counts": {"plan": 1, "act": 0, "reflect": 1} + }, + "proposal": [ + { + "proposal_id": "SafetyPolicy:ep_2024_abc123_s0:direction_min_confidence:0.8", + "policy_version": "1.0", + "kind": "threshold_tune", + "target": { + "path": "direction_min_confidence", + "from": 0.75, + "to": 0.8 + }, + "rationale": "Increase direction minimum confidence after veto.", + "evidence_ids": ["ep_2024_abc123_s0"], + "score_fn": "heuristic:veto_rate", + "score": 1.0, + "confidence": 1.0, + "status": "approved", + "metadata": { + "scorer": "heuristic:veto_rate", + "veto_rate": 1.0, + "direction_events": 1 + }, + "accepted": false + } + ], + "applied": false, + "scope": "policy", + "approval": "approved" + } +} ``` +`caused_by` is mandatory at the runtime persistence boundary even though the +`learn/1.0` schema permits extension fields. It must identify an upstream +record in the same episode's `events.jsonl`. `persist_episode_learning(...)` +raises `MissingCausalLinkError` before creating `learn.jsonl` when this value +is empty. + +An enabled learning pass can produce no proposals. In that case, +`learn.jsonl` may be absent or empty and the corresponding `learn` event has +an empty `proposal_ids` array. + ### Reading learn signals ```python @@ -428,13 +483,25 @@ import json from pathlib import Path learn_path = Path(f".noesis/episodes/{episode_id}/learn.jsonl") +events_path = learn_path.with_name("events.jsonl") if learn_path.exists(): - with open(learn_path) as f: - proposals = [json.loads(line) for line in f] - - for p in proposals: - print(f"Proposal: {p['type']} -> {p.get('target', p.get('key'))}") + records = [ + json.loads(line) + for line in learn_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + events_by_id = { + event["id"]: event + for line in events_path.read_text(encoding="utf-8").splitlines() + if (event := json.loads(line)).get("id") + } + + for record in records: + parent = events_by_id.get(record["caused_by"]) + if parent is None: + raise ValueError(f"Missing causal event for learn record {record['id']}") + print(record["id"], parent["phase"], record["payload"]["approval"]) ``` ## prompts.jsonl (opt-in) diff --git a/docs/reference/events.mdx b/docs/reference/events.mdx index 9e906ae..3b727e3 100644 --- a/docs/reference/events.mdx +++ b/docs/reference/events.mdx @@ -583,41 +583,56 @@ Evaluation metrics. ### learn -Captures learning signals for future episodes. The minimal validator expects the following keys; runners may add extra detail. +Points from the event timeline to proposal records in `learn.jsonl`. The runtime +links the event to the latest upstream event through `caused_by`. ```json { + "id": "evt_learn123", "phase": "learn", "payload": { - "policy_id": "policy:core.meta", - "basis": ["rules.veto.danger"], - "proposal": [], - "applied": false, - "scope": "episode" - } + "learn_path": "learn.jsonl", + "learn_schema": "learn/1.0", + "proposal_ids": ["SafetyPolicy:ep_2024_abc123_s0"], + "proposal_count": 1, + "applied": false + }, + "caused_by": "evt_terminate123" } ``` - -Policy that produced the learning signal. + +Relative path to the learning record stream. The runtime emits `learn.jsonl`. - -Reasons or evidence that drove the learning update. + +Schema identifier for records in the learning stream (`learn/1.0`). - -List of proposed updates (may be empty). + +IDs of proposal records persisted to `learn.jsonl`. This is empty when no +proposal record was written. - -Whether the proposed updates were applied. + +Number of proposals produced by this learning pass. - -Update scope: `session`, `episode`, `policy`, `global`. + +Whether this learning pass applied at least one proposal. + +ID of the latest upstream event. Persisted proposal records repeat this value +at the top level so consumers can join `learn.jsonl` back to `events.jsonl`. + + + +The validator also accepts the legacy inline payload shape (`policy_id`, +`basis`, `proposal`, and `scope`). Current runtime emission uses the artifact +reference shape above. + + ### runtime (`run.*`) Runtime events share `phase="runtime"` and use `event_type` as the stable subtype. diff --git a/docs/reference/python-api.mdx b/docs/reference/python-api.mdx index 6f699ad..56130e4 100644 --- a/docs/reference/python-api.mdx +++ b/docs/reference/python-api.mdx @@ -555,20 +555,76 @@ Configure via `ns.set(learn_mode="record")`. ```python from noesis.learn import LearnStatus -LearnStatus.PENDING # Awaiting review +LearnStatus.RECORDED # Persisted but not scored +LearnStatus.SCORED # Scored against the configured gate LearnStatus.APPROVED # Approved for application +LearnStatus.APPLIED # Applied to policy configuration LearnStatus.REJECTED # Rejected -LearnStatus.APPLIED # Already applied ``` ### LearnProposal -Dataclass for learning signals. Contains `kind`, `payload`, `confidence`, `status`, and metadata. +Dataclass for learning signals. Required fields are `proposal_id`, `policy_id`, +`policy_version`, `kind`, and `target`. It also carries rationale and evidence, +score/confidence, status, metadata, acceptance state, and an optional +`revert_handle`. -In v1.0.0, learning proposals are emitted automatically during summary finalization when `learn_mode` is enabled. When proposals are generated, they are written to `learn.jsonl` for the episode and tracked under `learn_home`. +Learning hooks run during summary finalization when `learn_mode` is enabled. +Eligible proposals are written to the episode's `learn.jsonl` and tracked +under `learn_home`. A pass with no eligible proposal can leave +`learn.jsonl` absent or empty. +### Causal persistence contract + +Every persisted learning record must link to an upstream event. The low-level +public helper makes `caused_by` required: + +```python +persist_episode_learning( + run_dir: Path, + *, + episode_id: str, + agent_id: str, + payload: dict[str, object], + caused_by: str, +) -> None +``` + +Use an event ID from the same episode. The automatic emitter selects the +latest non-empty event ID: + +```python +from pathlib import Path + +import noesis as ns +from noesis.learn import MissingCausalLinkError, persist_episode_learning + +episode_id = "ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C" +run_dir = Path(".noesis/episodes") / episode_id +events = list(ns.events.read(episode_id)) +parent_id = str(events[-1]["id"]) if events else "" + +try: + persist_episode_learning( + run_dir, + episode_id=episode_id, + agent_id="policy.release-notes", + payload={"policy_id": "policy.release-notes", "proposal": []}, + caused_by=parent_id, + ) +except MissingCausalLinkError: + # Do not write an orphaned learning record. + raise +``` + +`MissingCausalLinkError` subclasses `LearnCausalityError`. An empty +`caused_by` fails before the helper creates or appends to `learn.jsonl`. +Callers remain responsible for passing an ID that exists in the episode's +`events.jsonl`. Writes to a sealed episode are rejected by the artifact +immutability guard. + ### Helper functions ```python