Files
rune/scripts/process_experience.py
T
Nick ec2cba62ee
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / canary (push) Successful in 40s
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 4s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / unit-tests (push) Successful in 1m7s
CI Pipeline / lint (push) Successful in 37s
CI Pipeline / required-checks (push) Successful in 1s
feat(reachability): Live Rune Slice P0 — outcome producer edge + spine defect fixes
Runtime Reachability audit (reports/RUNTIME_REACHABILITY_AUDIT_2026-09-10.md,
4 parallel tracers + live canaries) established: ONE real end-to-end path
exists (send_task → ContextBroker → Ollama tool loop → durable events),
proven live by canary during the audit. The spine exists; the missing
pieces were edges, not architecture.

P0 implemented (choice-free items):

1. task.outcome producer edge: send_task emits a typed journal event on
   EVERY terminal transition (completed AND failed) with task/model/
   verification/route fields — the producer PL0/PL1 needs (outcome_ledger
   was de-wired; experience ledger had zero producers).
2. Experience consumer: scripts/process_experience.py
   record_operator_outcomes() reads the authoritative task-state
   checkpoint (the journal is a one-way integrity journal — payload_hash
   only) and appends idempotent SEED experience records. 132 live records
   created; rerun = 0 (idempotent).
3. Spine defect: _routing_profiles_path and the profile-save site had bare
   relative imports that fail in standalone CLI mode — trace recording
   degraded to tier-2 on every CLI run (mislabeled 'inference_trace').
   Fixed with the established dual-import pattern; verified clean run.
4. Standalone bootstrap: package parent now on sys.path for script
   execution.
5. hook_engine.fire dead call removed (rune_tui called a nonexistent
   function; AttributeError silently swallowed — hooks never ran). Left
   as an honest note, not a false-liveness surface.

Verification: Phase 8 canary through the real spine — all 9 rungs
observable from persisted state (trace v5 fields incl. memory
abstention + broker budget, routing reason, checkpoint outcome, journal
event, idempotent experience record). 1213 tests pass; ruff clean;
ratchet at baseline.

The route-authority placement choice (A/B/C) is presented in the audit
report §G and awaits Nick's decision before wiring adaptive routing into
the operator path.
2026-09-10 17:04:52 +01:00

189 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""process_experience.py — PL0 passive Experience Ledger (TASK I, epic #284).
Observation and evidence capture only. No autonomous self-editing.
Records Process Experience entries from pipeline runs: failures, successes,
inefficiencies — with root-cause attribution, evidence references, and
confidence. These are EVIDENCE RECORDS, not permanent rules.
Root-cause layers:
MODEL_BEHAVIOR — the model did something unexpected
PROMPT_CONTRACT — the prompt didn't communicate requirements clearly
SCHEMA — field names/structure mismatch
NORMALIZER — normalization was missing or incorrect
TOOL_INTERFACE — tool behavior differed from expectation
ARTIFACT_ASSEMBLY — artifact file composition issue
VALIDATOR — gate checked the wrong thing or missed something
ROUTING — the stage shouldn't have run for this task class
STAGE_ARCHITECTURE — the stage design itself is the problem
DETERMINISTIC_RUNTIME — deterministic code issue (not model)
EXTERNAL_DEPENDENCY — external service behaved unexpectedly
UNKNOWN — attribution uncertain
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
ROOT_CAUSE_LAYERS = {
"MODEL_BEHAVIOR",
"PROMPT_CONTRACT",
"SCHEMA",
"NORMALIZER",
"TOOL_INTERFACE",
"ARTIFACT_ASSEMBLY",
"VALIDATOR",
"ROUTING",
"STAGE_ARCHITECTURE",
"DETERMINISTIC_RUNTIME",
"EXTERNAL_DEPENDENCY",
"UNKNOWN",
}
RESULT_TYPES = {"SUCCESS", "FAILURE", "INEFFICIENCY"}
CANDIDATE_STATUSES = {"SEED", "EVALUATED", "PROMOTED", "REJECTED"}
class ExperienceLedger:
"""Append-only ledger of Process Experience records."""
def __init__(self, path: str):
self.path = path
os.makedirs(os.path.dirname(path), exist_ok=True)
def record(
self,
task_run_stage: str,
result_type: str,
symptom: str,
evidence: list[str],
root_cause: str,
confidence: float,
human_intervention: bool = False,
pipeline_sha: str = "UNKNOWN",
correction_applied: str = "",
recurrence_class: str = "",
wall_cost_s: float | str = 0,
model_cost_s: float | str = 0,
tool_cost_s: float | str = 0,
quality_outcome: str = "",
reusable: bool = False,
candidate_status: str = "SEED",
) -> dict:
assert result_type in RESULT_TYPES, f"invalid result_type: {result_type}"
assert root_cause in ROOT_CAUSE_LAYERS, f"invalid root_cause: {root_cause}"
assert candidate_status in CANDIDATE_STATUSES, f"invalid status: {candidate_status}"
entry = {
"experience_id": f"PE-{int(time.time())}-{len(self.load_all()) + 1:04d}",
"task_run_stage": task_run_stage,
"result_type": result_type,
"symptom": symptom,
"evidence_references": evidence,
"root_cause_layer": root_cause,
"confidence": confidence,
"human_intervention": human_intervention,
"pipeline_sha": pipeline_sha,
"correction_applied": correction_applied,
"recurrence_class": recurrence_class,
"wall_cost_s": wall_cost_s,
"model_cost_s": model_cost_s,
"tool_cost_s": tool_cost_s,
"quality_outcome": quality_outcome,
"reusable_lesson": reusable,
"candidate_status": candidate_status,
"recorded_at": datetime.now(timezone.utc).isoformat(),
}
with open(self.path, "a") as f:
f.write(json.dumps(entry) + "\n")
return entry
def load_all(self) -> list[dict]:
if not os.path.exists(self.path):
return []
out = []
with open(self.path) as f:
for line in f:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
def by_root_cause(self, layer: str) -> list[dict]:
return [e for e in self.load_all() if e["root_cause_layer"] == layer]
def successes(self) -> list[dict]:
return [e for e in self.load_all() if e["result_type"] == "SUCCESS"]
def failures(self) -> list[dict]:
return [e for e in self.load_all() if e["result_type"] == "FAILURE"]
def reusable_lessons(self) -> list[dict]:
return [e for e in self.load_all() if e["reusable_lesson"]]
def record_operator_outcomes(task_state_path: str | None = None, ledger_path: str | None = None) -> list[dict]:
"""Consume operator task outcomes into the Experience Ledger (Runtime
Reachability P0, 2026-09-10).
The operator's send_task persists every task in the TaskStateRepository
checkpoint (task_state.json colocated with operator.json) and emits a
durable task.outcome journal event per terminal transition. This
consumer reads the CHECKPOINT (the authoritative task store — the
journal is a one-way integrity journal: payload_hash only, no payload)
and appends a SEED experience record for every terminal task not yet
recorded (matched by evidence task_id — idempotent).
Attribution stays UNKNOWN: attribution is PL1's job, not the ledger's.
Statuses stay SEED/CANDIDATE — never auto-VALIDATED.
"""
path = task_state_path or os.path.expanduser("~/.rune/hot/task_state.json")
ledger = ExperienceLedger(
ledger_path
or os.environ.get("RUNE_EXPERIENCE_LEDGER", os.path.expanduser("~/.rune/state/process_experience.jsonl"))
)
with open(path) as f:
checkpoint = json.load(f)
tasks = checkpoint.get("tasks", [])
seen_tasks = set()
for rec in ledger.load_all():
for ev in rec.get("evidence_references", []):
if ev.startswith("task:"):
seen_tasks.add(ev.split(":", 1)[1])
recorded = []
for t in tasks:
status = t.get("status", "")
if status not in ("completed", "failed"):
continue
task_id = t.get("task_id", "")
if not task_id or task_id in seen_tasks:
continue
result_type = "SUCCESS" if status == "completed" else "FAILURE"
rec = ledger.record(
task_run_stage=f"operator:{status}",
result_type=result_type,
symptom=(t.get("title") or "")[:200],
evidence=[f"task:{task_id}", f"model:{t.get('assigned_model')}", f"outcome_ref:{t.get('outcome_ref')}"],
root_cause="UNKNOWN",
confidence=0.3,
pipeline_sha="operator",
reusable=False,
candidate_status="SEED",
)
recorded.append(rec)
return recorded
if __name__ == "__main__": # pragma: no cover — manual consumer invocation
import argparse
ap = argparse.ArgumentParser(description="Record operator task outcomes as experience evidence")
ap.add_argument("--task-state", default=None)
ap.add_argument("--ledger", default=None)
args = ap.parse_args()
recs = record_operator_outcomes(args.task_state, args.ledger)
print(f"recorded {len(recs)} experience records")