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
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.
189 lines
7.1 KiB
Python
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")
|