Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ jobs:
../../qa/test_orchestrate_split_rri.py \
../../qa/test_visual_regression_check.py \
../../qa/test_structural_coverage.py \
../../qa/test_story_readout_approval.py
../../qa/test_story_readout_approval.py \
../../qa/test_feature_engagement.py

server-contracts:
# Phase-1 (DETERMINISTIC-2): deterministic cross-service MCP contract tests
Expand Down
63 changes: 63 additions & 0 deletions qa/SCORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,69 @@ If the gate is RED, all three LLM scorecards are **capped to ≤ 2.5 / INVALID**
annotated with the failed checks (`worldos_cap_score_red`). A dead/non-progressing scene
can never display as 4.1 again. On a GREEN run, scores pass through untouched.

## 1b. Feature-engagement coverage — the dead-system tracker (WS0)
`qa/feature_engagement.py`

**The gap this closes.** The behavioral gate + the three lenses grade *prose, dice, and a few
structural floors*, but an entire authored subsystem (companion approval, camp downtime, faction
questlines, the companion agenda, decisions) could be **100% inert** across a whole sweep and the
RRI still scored 10/10 — *nothing was engagement-coverage*. A frozen-relationship run that
narrated the companion but never moved a gauge passed; a run that seeded factions and never joined
one passed.

**The manifest.** `feature_engagement.SYSTEMS` is the reviewed list of the 10 authored story
systems, each a `SystemSpec(id, precondition, detector, severity)`. A run is, per system:
- **ENGAGED** — the detector is true (the engine state / DM tool counts prove the system fired).
- **N/A** — the precondition is false (the run had no occasion: solo party, no factions seeded,
too short) **or unknown** (a beats-keyed precondition with no transcript beats count).
- **INERT** — the precondition is **true** and the detector is **false**: the system was *owed*
and never fired. **This is the signal.**

`engagement_coverage(state, tool_counts=None, session_beats=None)` returns
`{coverage, engaged[], na[], inert[{id,why,severity}]}`. It is **PURE-READ over engine-mutated
snapshot state** (`attitude_value`, `last_long_rest_day`, `faction.joined/standing`,
`narrative_arc.act`, `consequence.fired/trigger_day`, the arc/agenda `fired` flags,
`campaign.decisions/quests/factions/*_arcs`) **or DM tool-counts — never fiction/prose** (engine
invariant #3). It REUSES `story_readout.structural_coverage_from_state` /
`felt_shape_from_state` so the shared buckets never drift, and old snapshots round-trip (every
predicate null-guards a missing collection / a `None` `narrative_arc`).

**The forcing meta-test.** `servers/engine/tests/test_feature_engagement_manifest.py` mirrors
`test_tool_schema_budget.py`: it asserts `{s.id for s in SYSTEMS} == REVIEWED_SYSTEM_IDS`, so
adding/removing a tracked system is a **deliberate, visible diff** — the manifest can never
silently drift out of coverage (the exact failure WS0 exists to prevent).

**The deterministic RRI gate.** `qa/release_readiness.py` adds **one gate, `story_engagement`**
(in `DETERMINISTIC_GATES` — no live LLM). It rolls each persona's `engagement_coverage`
(merged into `score.json` by `qa/inject_structural_coverage.py`) up across the sweep: a system is
owed if **any** persona owed it, engaged if **any** persona engaged it; **inert for the sweep**
iff owed-by-≥1 **and** engaged-by-none. The gate **FAILS only on a FATAL inert system**. When
**no** persona block carries `engagement_coverage` (a legacy corpus), it is an **evidence-gap
SKIP** — excluded from `passed`/`total`, so the **RRI math** (`rri` / `gates_total` / `release_ready`)
stays **byte-identical** (mirrors the latency-gate skip). The serialized `rri.json` still gains the
additive `gate_detail.story_engagement` / `signals.engagement_*` keys (no value/verdict change). The
`ENGAGEMENT` report section names every inert system + a fix hint.

**Two N/A invariants (load-bearing — they keep the loop from ever false-RED-ing):**
- `session_beats` lives in the **transcript, not the snapshot**, so the signature accepts it
explicitly and **every beats-keyed precondition defaults to N/A when it is `None`** (the inject
callsite passes `None` → those systems are N/A there — safe under-detect).
- Under `WORLDOS_GATE_COMBAT_SPRINT`, all **FATAL** systems are **skipped** (mirrors
`assert_behavioral.py`) — a single pre-seeded fight exercises no story system.

**WARN-first → FATAL graduation discipline.** Every system ships **`severity='warn'` this PR**, so
the axis is **strictly additive**: it adds *zero* fatals to `assert_behavioral.py` and *cannot*
flip a currently-green run RED. **Graduation to FATAL is a FUTURE, post-sweep PR** — after one
real 5-persona sweep proves the inert/owed classification is calibrated (the same discipline as
`flat_arc` / `caster_has_spellbook`). **Two systems are BLOCKED and stay WARN regardless** —
`faction_arc` + `companion_quest_arc`: a snapshot-only precondition can't tell *seeded-but-locked*
from *never-seeded* (a known open spike), so they must never graduate until that is resolved.

**Where it surfaces.** `assert_behavioral.py` emits one `engagement_<id>` WARN per inert system
(additive, after `structural_completeness`); `inject_structural_coverage.py` merges the block into
each persona `score.json`; `scores_db.py` records `engagement_pct` + `engagement_inert`;
`release_readiness.py` gates + reports it.

## 2. The three LLM lenses (1–5 each; run concurrently)
Scored by `qa/score.sh` (claude -p) or `qa/score_openclaw.sh` (gpt-5.4, off the claude quota).

Expand Down
25 changes: 25 additions & 0 deletions qa/assert_behavioral.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
except Exception: # pragma: no cover - defensive: never let an import break the gate
coverage_from_tool_counts = None
felt_shape_from_state = None
# WS0 — the feature-engagement coverage scorer (manifest of authored story systems). Defensive
# import on the SAME pattern: a missing module degrades the engagement block to a no-op, never
# breaks the gate. All systems ship WARN, so this adds ZERO fatals (strictly additive).
try:
from feature_engagement import engagement_coverage
except Exception: # pragma: no cover - defensive
engagement_coverage = None


def _load_jsonl(p: str) -> list[dict]:
Expand Down Expand Up @@ -699,6 +706,24 @@ def _has_spells(c: dict) -> bool:
f"reversal + a late climax: record_decision the turn, complete_quest the spine late)",
fatal=False)

# ── WS0: FEATURE-ENGAGEMENT COVERAGE (the dead-system tracker; ALL-WARN this PR) ──────
# Today an entire authored subsystem (companion approval, camp downtime, faction questlines,
# …) can be 100% INERT across a whole run and every gate/lens still scores it 10/10 — no gate
# is engagement-coverage. This block reads the engine snapshot + the DM tool counts (the SAME
# ground-truth surfaces the structural_completeness floor uses — never fiction) and, for each
# system the run was OWED but never engaged, emits a chk. Severity rides the manifest: every
# system ships 'warn' this PR, so this adds ZERO fatals (every currently-green run stays
# green). FATAL graduation is a FUTURE, post-sweep PR. The combat-sprint env skip is honored
# inside engagement_coverage (mirrors the world-progression / structural floors above).
if engagement_coverage is not None:
try:
eng = engagement_coverage(state, dict(tools), session_beats)
except Exception: # pragma: no cover - the engagement scorer must never break the gate
eng = {"inert": []}
for item in eng.get("inert", []):
chk(f"engagement_{item.get('id', '?')}", False, item.get("why", ""),
fatal=(item.get("severity") == "fatal"))

# ── SECTION A: RESULT-SIDE + per-record state gates (audit-tests.md §A) ───────────────
# These read artifacts the existing gates ignore: the tool_RESULT payloads (A1/A2/A8) and
# per-record final state that's present-but-unchecked (A3 living monster, A5 PC XP, A6
Expand Down
Loading
Loading