51b886f3f6
REQ-317: core/metrics/outcome_backfill.py backfills fact_decision.outcome pending -> succeeded/failed after run.completed/run.failed; idempotent + terminal (does not overwrite a non-pending outcome); wired into the collector. The Post-Pilot AI Decision Accuracy denominator is now grounded (fact_decision.outcome is not stuck pending). REQ-318: ai.decision.made on a block band carries escalation_reason: 'confidence' (the only value in v1.26 — a block is always confidence- driven; future milestones may add 'policy'). Persisted into fact_run by the collector. The Post-Pilot Human Escalation Frequency denominator is now grounded. ---ci--- project: acdl phase: 3 milestone: v1.26 status: execute wave: W2 ---
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""Tests for Nova Outcome Backfill (REQ-317, SPEC §5.8, P3 W2).
|
|
|
|
Covers the fact_decision.outcome transition pending -> succeeded/failed:
|
|
* happy path (succeeded, failed)
|
|
* idempotency (already_backfilled is a no-op)
|
|
* terminal defense (does NOT flip succeeded -> failed)
|
|
* invalid outcome raises ValueError
|
|
* unknown decision_id raises KeyError
|
|
|
|
Uses a tmp SQLite cold store + Decision Ledger (does NOT touch the real
|
|
metrics/decision_ledger.db). Follows the fixture pattern in
|
|
tests/test_metrics_collector.py + tests/test_metrics_emitters.py.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_backfill_env(tmp_path, monkeypatch):
|
|
"""Redirect metrics/ to a tmp dir + seed a fact_decision row (pending)."""
|
|
metrics_dir = tmp_path / "metrics"
|
|
metrics_dir.mkdir()
|
|
store_db = metrics_dir / "nova_metrics.db"
|
|
ledger_db = metrics_dir / "decision_ledger.db"
|
|
events_log = metrics_dir / "events.jsonl"
|
|
|
|
monkeypatch.setattr("core.metrics.event_envelope.METRICS_DIR", str(metrics_dir))
|
|
monkeypatch.setattr("core.metrics.event_envelope.EVENTS_LOG", str(events_log))
|
|
monkeypatch.setattr("core.metrics.decision_ledger._LEDGER_PATH", str(ledger_db))
|
|
monkeypatch.setattr("core.metrics.outcome_backfill._METRICS_DIR", str(metrics_dir))
|
|
monkeypatch.setattr("core.metrics.outcome_backfill._STORE_PATH", str(store_db))
|
|
monkeypatch.setattr("core.metrics.outcome_backfill._LEDGER_PATH", str(ledger_db))
|
|
|
|
# Initialize the cold store schema + a pending fact_decision row.
|
|
from core.metrics.collector import _init_store
|
|
_init_store(str(store_db))
|
|
|
|
conn = sqlite3.connect(str(store_db))
|
|
conn.execute(
|
|
"INSERT INTO fact_decision "
|
|
"(decision_id, run_id, chosen_action, confidence, alternatives, "
|
|
"human_override, escalation_reason, outcome, backfilled_at, event_time) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
("dec-1", "run-1", "block", 0.42, "{}", 1, "confidence",
|
|
"pending", None, "2026-08-18T00:00:00Z"),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {
|
|
"metrics_dir": metrics_dir,
|
|
"store_db": store_db,
|
|
"ledger_db": ledger_db,
|
|
"events_log": events_log,
|
|
"decision_id": "dec-1",
|
|
}
|
|
|
|
|
|
def _get_fact_decision(store_db, decision_id):
|
|
conn = sqlite3.connect(str(store_db))
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT decision_id, outcome, backfilled_at FROM fact_decision WHERE decision_id = ?",
|
|
(decision_id,),
|
|
).fetchone()
|
|
conn.close()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def test_backfill_succeeded(tmp_backfill_env):
|
|
from core.metrics.outcome_backfill import backfill
|
|
result = backfill(tmp_backfill_env["decision_id"], "succeeded")
|
|
assert result["status"] == "backfilled"
|
|
assert result["previous_outcome"] == "pending"
|
|
assert result["new_outcome"] == "succeeded"
|
|
assert result["backfilled_at"]
|
|
fact = _get_fact_decision(tmp_backfill_env["store_db"], "dec-1")
|
|
assert fact["outcome"] == "succeeded"
|
|
assert fact["backfilled_at"] == result["backfilled_at"]
|
|
|
|
|
|
def test_backfill_failed(tmp_backfill_env):
|
|
from core.metrics.outcome_backfill import backfill
|
|
result = backfill(tmp_backfill_env["decision_id"], "failed")
|
|
assert result["status"] == "backfilled"
|
|
assert result["new_outcome"] == "failed"
|
|
fact = _get_fact_decision(tmp_backfill_env["store_db"], "dec-1")
|
|
assert fact["outcome"] == "failed"
|
|
|
|
|
|
def test_backfill_idempotent(tmp_backfill_env):
|
|
"""Already succeeded → backfill('succeeded') again → no-op."""
|
|
from core.metrics.outcome_backfill import backfill
|
|
backfill(tmp_backfill_env["decision_id"], "succeeded")
|
|
result = backfill(tmp_backfill_env["decision_id"], "succeeded")
|
|
assert result["status"] == "already_backfilled"
|
|
assert result["existing_outcome"] == "succeeded"
|
|
fact = _get_fact_decision(tmp_backfill_env["store_db"], "dec-1")
|
|
assert fact["outcome"] == "succeeded"
|
|
|
|
|
|
def test_backfill_does_not_overwrite(tmp_backfill_env):
|
|
"""Already succeeded → backfill('failed') → must NOT flip to failed.
|
|
|
|
A terminal outcome is never overwritten (defense against double-backfill
|
|
and against retroactively flipping succeeded -> failed).
|
|
"""
|
|
from core.metrics.outcome_backfill import backfill
|
|
backfill(tmp_backfill_env["decision_id"], "succeeded")
|
|
result = backfill(tmp_backfill_env["decision_id"], "failed")
|
|
assert result["status"] == "already_backfilled"
|
|
assert result["existing_outcome"] == "succeeded"
|
|
fact = _get_fact_decision(tmp_backfill_env["store_db"], "dec-1")
|
|
assert fact["outcome"] == "succeeded"
|
|
|
|
|
|
def test_backfill_invalid_outcome(tmp_backfill_env):
|
|
from core.metrics.outcome_backfill import backfill
|
|
with pytest.raises(ValueError):
|
|
backfill(tmp_backfill_env["decision_id"], "pending")
|
|
with pytest.raises(ValueError):
|
|
backfill(tmp_backfill_env["decision_id"], "garbage")
|
|
|
|
|
|
def test_backfill_unknown_decision(tmp_backfill_env):
|
|
from core.metrics.outcome_backfill import backfill
|
|
with pytest.raises(KeyError):
|
|
backfill("nonexistent-decision-id", "succeeded")
|
|
|
|
|
|
def test_backfill_appends_ledger_event(tmp_backfill_env):
|
|
"""The backfill appends nova.outcome.backfilled to the Decision Ledger
|
|
(preserves the hash chain — the ledger is never UPDATEd in place)."""
|
|
from core.metrics.outcome_backfill import backfill
|
|
from core.metrics.decision_ledger import query_by_run, verify_chain
|
|
backfill(tmp_backfill_env["decision_id"], "succeeded")
|
|
entries = query_by_run("run-1", db_path=str(tmp_backfill_env["ledger_db"]))
|
|
types = [e["event_type"] for e in entries]
|
|
assert "nova.outcome.backfilled" in types
|
|
# Hash chain still intact.
|
|
ok, broken, _ = verify_chain(db_path=str(tmp_backfill_env["ledger_db"]))
|
|
assert ok, f"chain broken after backfill: {broken}"
|
|
assert broken == 0 |