Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions docs/explanation/artifacts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -412,29 +412,96 @@ 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
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)
Expand Down
49 changes: 32 additions & 17 deletions docs/reference/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

<ResponseField name="payload.policy_id" type="string" required>
Policy that produced the learning signal.
<ResponseField name="payload.learn_path" type="string" required>
Relative path to the learning record stream. The runtime emits `learn.jsonl`.
</ResponseField>

<ResponseField name="payload.basis" type="array" required>
Reasons or evidence that drove the learning update.
<ResponseField name="payload.learn_schema" type="string" required>
Schema identifier for records in the learning stream (`learn/1.0`).
</ResponseField>

<ResponseField name="payload.proposal" type="array" required>
List of proposed updates (may be empty).
<ResponseField name="payload.proposal_ids" type="array" required>
IDs of proposal records persisted to `learn.jsonl`. This is empty when no
proposal record was written.
</ResponseField>

<ResponseField name="payload.applied" type="boolean" required>
Whether the proposed updates were applied.
<ResponseField name="payload.proposal_count" type="integer" required>
Number of proposals produced by this learning pass.
</ResponseField>

<ResponseField name="payload.scope" type="string" required>
Update scope: `session`, `episode`, `policy`, `global`.
<ResponseField name="payload.applied" type="boolean">
Whether this learning pass applied at least one proposal.
</ResponseField>

<ResponseField name="caused_by" type="string">
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`.
</ResponseField>

<Note>
The validator also accepts the legacy inline payload shape (`policy_id`,
`basis`, `proposal`, and `scope`). Current runtime emission uses the artifact
reference shape above.
</Note>

### runtime (`run.*`)

Runtime events share `phase="runtime"` and use `event_type` as the stable subtype.
Expand Down
64 changes: 60 additions & 4 deletions docs/reference/python-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<Note>
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.
</Note>

### 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
Expand Down
Loading