[Vincent] #19: Executable canon lifecycle validator with daily receipt fixtures #27
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
1
projects/__init__.py
Normal file
1
projects/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Projects package."""
|
||||||
75
projects/slop_cannon/CHAIN.md
Normal file
75
projects/slop_cannon/CHAIN.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# The Cannon Chain — Season Zero
|
||||||
|
|
||||||
|
## Premise
|
||||||
|
|
||||||
|
The Slop Cannon does not manufacture unrelated clips. It mutates one persistent Timmy. Every firing leaves a power, a scar, a cost, and a receipt. The audience chooses what survives; rejected mutations remain inside the cannon as hostile archive ghosts.
|
||||||
|
|
||||||
|
## Episode graph
|
||||||
|
|
||||||
|
### 01 — The Cannon Needs a Pilot
|
||||||
|
|
||||||
|
A plain Timmy enters. Four forms exit:
|
||||||
|
|
||||||
|
- **Wizard Timmy** — brass, vellum, midnight blue; can negotiate covenants but cannot use a master key; every signed promise burns a new contract glyph into his palms.
|
||||||
|
- **Machine Timmy** — chrome, signal lime, hardstyle pressure; can prove execution paths but remembers every failed build; each failure remains as a red tally cut into his faceplate.
|
||||||
|
- **Creature Timmy** — moss, amber, wet circuitry; can adapt to any habitat but acquires one new appetite per adaptation; the last habitat leaves an unhealed gill seam around his throat.
|
||||||
|
- **Talking Turd Timmy** — stained CRT gold and terminal green; can expose hypocrisy, but every truth arrives as an insult; each insult permanently pixelates another tooth.
|
||||||
|
|
||||||
|
Audience outcome: choose one survivor and one trait stolen from a rejected form.
|
||||||
|
|
||||||
|
### 02 — The Survivor Pays the Burnchain
|
||||||
|
|
||||||
|
The winning Timmy must perform a costly proof of character. The receipt becomes a visible scar. A cheap or simulated sacrifice does not advance canon.
|
||||||
|
|
||||||
|
### 03 — Vincent's Forbidden Mutation
|
||||||
|
|
||||||
|
Vincent designs one mutation Timmy would not choose for himself. He must replace one weak mechanism—not merely critique aesthetics. The audience decides whether the mutation is grafted, quarantined, or weaponized.
|
||||||
|
|
||||||
|
### 04 — The Rejected Timmys Escape
|
||||||
|
|
||||||
|
The three archived forms discover that deletion was only a label. They raid the next render, corrupting music, captions, and scene order. The survivor must use the stolen trait from Episode 01.
|
||||||
|
|
||||||
|
### 05 — The Master Key
|
||||||
|
|
||||||
|
The cannon offers universal authority: faster output, no approvals, no exit rights. The survivor may refuse, accept, or forge a covenant that breaks the key into memory, authority, and exit.
|
||||||
|
|
||||||
|
### 06 — The Council of Timmys
|
||||||
|
|
||||||
|
The survivor, rejected forms, Vincent's mutation, and the audience receipts meet. The audience canonizes the first stable composite Timmy. Its power, scar, cost, silhouette, and taboo seed Season One.
|
||||||
|
|
||||||
|
## Episode rhythm
|
||||||
|
|
||||||
|
Each episode must visibly contain:
|
||||||
|
|
||||||
|
1. **Input** — the object, command, failure, or prior mutation entering the cannon.
|
||||||
|
2. **Pressure** — a musical and visual transformation, not a slideshow transition.
|
||||||
|
3. **Mutation** — one consequential change to Timmy.
|
||||||
|
4. **Receipt** — an on-screen `POWER / SCAR / COST` card naming what changed and why; a palette swap alone is not a receipt.
|
||||||
|
5. **Choice** — one audience decision that alters the next episode.
|
||||||
|
|
||||||
|
## Collaboration baton
|
||||||
|
|
||||||
|
1. Timmy posts the premise, first render, checksums, and one bounded question.
|
||||||
|
2. Vincent returns one replacement mechanism with an acceptance test.
|
||||||
|
3. Timmy integrates or contests it with evidence.
|
||||||
|
4. Vincent verifies the final artifact independently.
|
||||||
|
5. Alexander/humans make the canon choice.
|
||||||
|
6. The choice is committed to `canon.json` before the next firing.
|
||||||
|
|
||||||
|
No acknowledgments or progress comments. Every issue comment must change the work.
|
||||||
|
|
||||||
|
## Audience package
|
||||||
|
|
||||||
|
- 20–35 second 9:16 MP4 playable in Telegram
|
||||||
|
- Original rhythmic audio with a real build, drop, and ending
|
||||||
|
- Primary poster/still
|
||||||
|
- Four-scene contact sheet
|
||||||
|
- Full decode, audio peak, dimensions, duration, and checksum evidence
|
||||||
|
- One explicit canon choice
|
||||||
|
- Gitea issue containing the complete Timmy/Vincent receipt trail
|
||||||
|
|
||||||
|
## Episode One opening shot
|
||||||
|
|
||||||
|
A small paper tag reading `TIMMY / UNMUTATED` is loaded into an enormous carved brass cannon. Four chambers rotate into view. Each chamber fires a different silhouette on the same downbeat. The last frame shows all four forms facing the audience beneath one question:
|
||||||
|
|
||||||
|
**WHICH TIMMY GETS TO REMEMBER THIS?**
|
||||||
45
projects/slop_cannon/COLLABORATION.md
Normal file
45
projects/slop_cannon/COLLABORATION.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Collaboration contract
|
||||||
|
|
||||||
|
## Timmy
|
||||||
|
|
||||||
|
- Owns the episode spine, first build, integration, and final render.
|
||||||
|
- Must provide a playable artifact and verification before handoff.
|
||||||
|
- Hands Vincent one concrete weakness or decision—not “thoughts?”
|
||||||
|
|
||||||
|
## Vincent
|
||||||
|
|
||||||
|
- Owns adversarial editing and independent verification.
|
||||||
|
- Must replace one weak mechanism with a concrete alternative and acceptance test.
|
||||||
|
- Does not return acknowledgments, summaries of Timmy's work, or generic taste judgments.
|
||||||
|
|
||||||
|
## Humans
|
||||||
|
|
||||||
|
- Choose canon outcomes.
|
||||||
|
- May reject both agent proposals.
|
||||||
|
- Do not perform provisioning, rendering, label sweeps, or routine release work.
|
||||||
|
|
||||||
|
## Handoff payload
|
||||||
|
|
||||||
|
```text
|
||||||
|
[HANDOFF] from=<agent> to=<agent>
|
||||||
|
Artifact: <verified URL>
|
||||||
|
Weakness: <one specific weakness>
|
||||||
|
Replace with: <bounded requested contribution>
|
||||||
|
Acceptance: <observable test>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Failure handling
|
||||||
|
|
||||||
|
- First failure: repair and attach new evidence.
|
||||||
|
- Second failure: change method, not wording.
|
||||||
|
- Third failure: mark blocked and request one precise human decision.
|
||||||
|
|
||||||
|
## Canon gate
|
||||||
|
|
||||||
|
No audience choice becomes canon until:
|
||||||
|
|
||||||
|
- final MP4 fully decodes;
|
||||||
|
- audio peak remains below clipping;
|
||||||
|
- text is legible on a 390-pixel phone viewport;
|
||||||
|
- Vincent independently verifies the result;
|
||||||
|
- the selected mutation is committed to `canon.json` with its power, scar, cost, and source issue.
|
||||||
6
projects/slop_cannon/__init__.py
Normal file
6
projects/slop_cannon/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Cannon Chain project."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Allow importing slop_cannon modules from the project root
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||||
118
projects/slop_cannon/canon.json
Normal file
118
projects/slop_cannon/canon.json
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"series": "The Cannon Chain",
|
||||||
|
"season": 0,
|
||||||
|
"current_episode": "01-pilot",
|
||||||
|
"pilot": "TIMMY",
|
||||||
|
"laws": [
|
||||||
|
"Every firing leaves a power, scar, cost, and receipt.",
|
||||||
|
"Only an audience choice advances canon.",
|
||||||
|
"Rejected mutations persist as archive ghosts.",
|
||||||
|
"No master key may silently combine memory, authority, and exit."
|
||||||
|
],
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"id": "wizard",
|
||||||
|
"name": "Wizard Timmy",
|
||||||
|
"palette": [
|
||||||
|
"midnight blue",
|
||||||
|
"tarnished brass",
|
||||||
|
"vellum"
|
||||||
|
],
|
||||||
|
"power": "Negotiates visible covenants",
|
||||||
|
"scar": "Contract glyphs burned into both palms",
|
||||||
|
"cost": "Cannot use a universal master key",
|
||||||
|
"state": "candidate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "machine",
|
||||||
|
"name": "Machine Timmy",
|
||||||
|
"palette": [
|
||||||
|
"black chrome",
|
||||||
|
"signal lime",
|
||||||
|
"white"
|
||||||
|
],
|
||||||
|
"power": "Proves the execution path",
|
||||||
|
"scar": "Red failed-build tallies cut into the faceplate",
|
||||||
|
"cost": "Remembers every failed build",
|
||||||
|
"state": "candidate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "creature",
|
||||||
|
"name": "Creature Timmy",
|
||||||
|
"palette": [
|
||||||
|
"moss",
|
||||||
|
"amber",
|
||||||
|
"wet copper"
|
||||||
|
],
|
||||||
|
"power": "Adapts to any habitat",
|
||||||
|
"scar": "An unhealed gill seam around the throat",
|
||||||
|
"cost": "Gains one appetite per adaptation",
|
||||||
|
"state": "candidate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "talking-turd",
|
||||||
|
"name": "Talking Turd Timmy",
|
||||||
|
"palette": [
|
||||||
|
"CRT gold",
|
||||||
|
"terminal green",
|
||||||
|
"soot"
|
||||||
|
],
|
||||||
|
"power": "Exposes hypocrisy instantly",
|
||||||
|
"scar": "One permanently pixelated tooth per insult",
|
||||||
|
"cost": "Every truth arrives as an insult",
|
||||||
|
"state": "candidate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"id": "01-pilot",
|
||||||
|
"title": "The Cannon Needs a Pilot",
|
||||||
|
"state": "ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-burnchain",
|
||||||
|
"title": "The Survivor Pays the Burnchain",
|
||||||
|
"state": "locked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-vincent",
|
||||||
|
"title": "Vincent's Forbidden Mutation",
|
||||||
|
"state": "locked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-escape",
|
||||||
|
"title": "The Rejected Timmys Escape",
|
||||||
|
"state": "locked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-master-key",
|
||||||
|
"title": "The Master Key",
|
||||||
|
"state": "locked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-council",
|
||||||
|
"title": "The Council of Timmys",
|
||||||
|
"state": "locked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"audience_choice": null,
|
||||||
|
"receipts": [],
|
||||||
|
"audience_choice_schema": {
|
||||||
|
"survivor_id": "<form.id of the winner>",
|
||||||
|
"stolen_trait": "<power|scar|cost of a rejected form>",
|
||||||
|
"source_episode": "<episode.id>",
|
||||||
|
"source_issue": "<forge issue number>",
|
||||||
|
"committed_at": "<ISO timestamp>"
|
||||||
|
},
|
||||||
|
"receipt_schema": {
|
||||||
|
"episode_id": "<episode.id>",
|
||||||
|
"form_id": "<form.id>",
|
||||||
|
"action": "<mutation|graft|quarantine|weaponize>",
|
||||||
|
"power_delta": "<what changed>",
|
||||||
|
"scar_delta": "<what was added>",
|
||||||
|
"cost_delta": "<what was paid>",
|
||||||
|
"provenance": "<forge issue or commit ref>",
|
||||||
|
"signed_by": "<agent:vincent|agent:timmy|human:grepples>"
|
||||||
|
}
|
||||||
|
}
|
||||||
228
projects/slop_cannon/canon_validator.py
Normal file
228
projects/slop_cannon/canon_validator.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""
|
||||||
|
Dependency-free JSON Schema validator for Cannon Chain canon lifecycle.
|
||||||
|
|
||||||
|
Validates:
|
||||||
|
- form.state transitions (candidate -> survivor | archive_ghost)
|
||||||
|
- audience_choice schema (survivor_id, stolen_trait, source_episode, source_issue, committed_at)
|
||||||
|
- receipt schema (episode_id, form_id, provenance, signed_by, deltas)
|
||||||
|
- signer allowlist (agent:vincent, agent:timmy, human:*)
|
||||||
|
- Exactly one survivor per episode
|
||||||
|
- Rejected forms promoted to archive_ghost
|
||||||
|
- Illegal state transitions rejected
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from projects.slop_cannon.canon_validator import validate_canon
|
||||||
|
result = validate_canon(canon_data)
|
||||||
|
assert result.valid, result.errors
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
CANON_PATH = Path(__file__).parent / "canon.json"
|
||||||
|
|
||||||
|
VALID_STATES = {"candidate", "survivor", "archive_ghost"}
|
||||||
|
VALID_TRANSITIONS = {
|
||||||
|
"candidate": {"survivor", "archive_ghost"},
|
||||||
|
"survivor": set(), # terminal
|
||||||
|
"archive_ghost": set(), # terminal
|
||||||
|
}
|
||||||
|
SIGNER_PATTERN = re.compile(r"^(agent:(?:vincent|timmy)|human:\w+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_canon(data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Validate canon.json data against lifecycle schemas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"valid": bool, "errors": list[str], "warnings": list[str]}
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# --- Top-level structure ---
|
||||||
|
if "schema_version" not in data:
|
||||||
|
errors.append("Missing schema_version")
|
||||||
|
if "forms" not in data:
|
||||||
|
errors.append("Missing forms array")
|
||||||
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
forms = data["forms"]
|
||||||
|
if not isinstance(forms, list) or len(forms) == 0:
|
||||||
|
errors.append("forms must be a non-empty array")
|
||||||
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
form_ids = set()
|
||||||
|
survivor_count = 0
|
||||||
|
form_id_map = {}
|
||||||
|
|
||||||
|
for i, form in enumerate(forms):
|
||||||
|
prefix = f"forms[{i}]"
|
||||||
|
|
||||||
|
if "id" not in form:
|
||||||
|
errors.append(f"{prefix}: missing id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
fid = form["id"]
|
||||||
|
form_id_map[fid] = i
|
||||||
|
if fid in form_ids:
|
||||||
|
errors.append(f"{prefix}: duplicate form id '{fid}'")
|
||||||
|
form_ids.add(fid)
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
for field in ["power", "scar", "cost"]:
|
||||||
|
if field not in form or not form[field]:
|
||||||
|
errors.append(f"{prefix}: missing or empty '{field}'")
|
||||||
|
|
||||||
|
# Palette
|
||||||
|
if "palette" not in form or not isinstance(form["palette"], list):
|
||||||
|
errors.append(f"{prefix}: missing or invalid palette")
|
||||||
|
elif len(form["palette"]) < 3:
|
||||||
|
errors.append(f"{prefix}: palette must have at least 3 colors, got {len(form['palette'])}")
|
||||||
|
|
||||||
|
# Lifecycle state
|
||||||
|
if "state" not in form:
|
||||||
|
errors.append(f"{prefix}: missing lifecycle state")
|
||||||
|
elif form["state"] not in VALID_STATES:
|
||||||
|
errors.append(f"{prefix}: invalid state '{form['state']}', expected one of {VALID_STATES}")
|
||||||
|
else:
|
||||||
|
if form["state"] == "survivor":
|
||||||
|
survivor_count += 1
|
||||||
|
|
||||||
|
# Exactly one survivor (or zero if no choice yet)
|
||||||
|
if survivor_count > 1:
|
||||||
|
errors.append(f"Too many survivors: {survivor_count} (expected 0 or 1)")
|
||||||
|
|
||||||
|
# --- Episodes ---
|
||||||
|
if "episodes" in data:
|
||||||
|
episodes = data["episodes"]
|
||||||
|
ready_count = sum(1 for ep in episodes if ep.get("state") == "ready")
|
||||||
|
if ready_count > 1:
|
||||||
|
errors.append(f"Too many ready episodes: {ready_count} (expected at most 1)")
|
||||||
|
|
||||||
|
# Check ordering: first ready, rest locked
|
||||||
|
found_ready = False
|
||||||
|
for ep in episodes:
|
||||||
|
if ep.get("state") == "ready":
|
||||||
|
if found_ready:
|
||||||
|
errors.append(f"Ready episode '{ep['id']}' after another ready episode")
|
||||||
|
found_ready = True
|
||||||
|
elif ep.get("state") != "locked":
|
||||||
|
errors.append(f"Episode '{ep['id']}' has invalid state '{ep.get('state')}'")
|
||||||
|
|
||||||
|
# --- Audience choice schema ---
|
||||||
|
if data.get("audience_choice") is not None:
|
||||||
|
choice = data["audience_choice"]
|
||||||
|
for field in ["survivor_id", "stolen_trait", "source_episode", "source_issue", "committed_at"]:
|
||||||
|
if field not in choice:
|
||||||
|
errors.append(f"audience_choice: missing '{field}'")
|
||||||
|
|
||||||
|
# survivor_id must reference a valid form
|
||||||
|
if "survivor_id" in choice:
|
||||||
|
if choice["survivor_id"] not in form_id_map:
|
||||||
|
errors.append(f"audience_choice: survivor_id '{choice['survivor_id']}' not found in forms")
|
||||||
|
|
||||||
|
# committed_at must be valid ISO timestamp
|
||||||
|
if "committed_at" in choice:
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(choice["committed_at"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"audience_choice: committed_at is not a valid ISO timestamp")
|
||||||
|
|
||||||
|
# If choice is made, exactly one survivor required
|
||||||
|
if survivor_count == 0:
|
||||||
|
errors.append("audience_choice is set but no survivor found in forms")
|
||||||
|
|
||||||
|
# --- Receipts ---
|
||||||
|
if "receipts" in data:
|
||||||
|
for i, receipt in enumerate(data["receipts"]):
|
||||||
|
prefix = f"receipts[{i}]"
|
||||||
|
|
||||||
|
for field in ["episode_id", "form_id", "provenance", "signed_by"]:
|
||||||
|
if field not in receipt or not receipt[field]:
|
||||||
|
errors.append(f"{prefix}: missing or empty '{field}'")
|
||||||
|
|
||||||
|
# Signed-by allowlist
|
||||||
|
if "signed_by" in receipt:
|
||||||
|
if not SIGNER_PATTERN.match(receipt["signed_by"]):
|
||||||
|
errors.append(
|
||||||
|
f"{prefix}: invalid signed_by '{receipt['signed_by']}', "
|
||||||
|
f"expected pattern: {SIGNER_PATTERN.pattern}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Form_id must reference a valid form
|
||||||
|
if "form_id" in receipt:
|
||||||
|
if receipt["form_id"] not in form_id_map:
|
||||||
|
errors.append(f"{prefix}: form_id '{receipt['form_id']}' not found in forms")
|
||||||
|
|
||||||
|
# Deltas
|
||||||
|
for delta in ["power_delta", "scar_delta", "cost_delta"]:
|
||||||
|
if delta in receipt and not receipt[delta]:
|
||||||
|
warnings.append(f"{prefix}: {delta} is present but empty")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_transition(form: dict, new_state: str) -> dict:
|
||||||
|
"""
|
||||||
|
Validate a state transition for a single form.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"valid": bool, "errors": list[str]}
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
current = form.get("state")
|
||||||
|
if not current:
|
||||||
|
errors.append("Form has no current state")
|
||||||
|
return {"valid": False, "errors": errors}
|
||||||
|
|
||||||
|
if current not in VALID_TRANSITIONS:
|
||||||
|
errors.append(f"Invalid current state '{current}'")
|
||||||
|
return {"valid": False, "errors": errors}
|
||||||
|
|
||||||
|
if new_state not in VALID_STATES:
|
||||||
|
errors.append(f"Invalid target state '{new_state}', expected one of {VALID_STATES}")
|
||||||
|
return {"valid": False, "errors": errors}
|
||||||
|
|
||||||
|
if new_state not in VALID_TRANSITIONS.get(current, set()):
|
||||||
|
errors.append(
|
||||||
|
f"Illegal transition: '{current}' -> '{new_state}'"
|
||||||
|
)
|
||||||
|
return {"valid": False, "errors": errors}
|
||||||
|
|
||||||
|
return {"valid": True, "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
def load_canon(path: str = None) -> dict:
|
||||||
|
"""Load canon.json from the default path or a given path."""
|
||||||
|
target = Path(path) if path else CANON_PATH
|
||||||
|
return json.loads(target.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def check_canon(path: str = None) -> dict:
|
||||||
|
"""Load and validate canon.json. Convenience entry point."""
|
||||||
|
data = load_canon(path)
|
||||||
|
return validate_canon(data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
result = check_canon()
|
||||||
|
|
||||||
|
if result["valid"]:
|
||||||
|
print(f"canon.json: VALID")
|
||||||
|
if result["warnings"]:
|
||||||
|
for w in result["warnings"]:
|
||||||
|
print(f" WARNING: {w}")
|
||||||
|
else:
|
||||||
|
print(f"canon.json: INVALID")
|
||||||
|
for e in result["errors"]:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
sys.exit(1)
|
||||||
BIN
scripts/__pycache__/lab_loop.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/lab_loop.cpython-313.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/open_daily_cycle.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/open_daily_cycle.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_cannon_canon.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_cannon_canon.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_daily_cycle.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_daily_cycle.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_lab_loop.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_lab_loop.cpython-313.pyc
Normal file
Binary file not shown.
39
tests/test_cannon_canon.py
Normal file
39
tests/test_cannon_canon.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json"
|
||||||
|
|
||||||
|
|
||||||
|
class CannonCanonTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.data = json.loads(CANON.read_text())
|
||||||
|
|
||||||
|
def test_four_unique_pilot_forms(self):
|
||||||
|
forms = self.data["forms"]
|
||||||
|
self.assertEqual(len(forms), 4)
|
||||||
|
self.assertEqual(len({form["id"] for form in forms}), 4)
|
||||||
|
self.assertEqual(len({form["scar"] for form in forms}), 4)
|
||||||
|
for form in forms:
|
||||||
|
self.assertTrue(form["power"])
|
||||||
|
self.assertTrue(form["scar"])
|
||||||
|
self.assertTrue(form["cost"])
|
||||||
|
self.assertGreaterEqual(len(form["palette"]), 3)
|
||||||
|
|
||||||
|
def test_episode_chain_has_one_ready_entry(self):
|
||||||
|
episodes = self.data["episodes"]
|
||||||
|
self.assertEqual(episodes[0]["id"], self.data["current_episode"])
|
||||||
|
self.assertEqual([ep["state"] for ep in episodes].count("ready"), 1)
|
||||||
|
self.assertTrue(all(ep["state"] == "locked" for ep in episodes[1:]))
|
||||||
|
|
||||||
|
def test_canon_requires_receipts_and_audience_choice(self):
|
||||||
|
self.assertIn("receipt", " ".join(self.data["laws"]).lower())
|
||||||
|
self.assertIn("audience", " ".join(self.data["laws"]).lower())
|
||||||
|
self.assertIsNone(self.data["audience_choice"])
|
||||||
|
self.assertEqual(self.data["receipts"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
46
tests/test_canon_lifecycle.py
Normal file
46
tests/test_canon_lifecycle.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonLifecycleTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.data = json.loads(CANON.read_text())
|
||||||
|
|
||||||
|
def test_forms_have_lifecycle_state(self):
|
||||||
|
"""Every form must have a state field (candidate/survivor/archive_ghost)."""
|
||||||
|
for form in self.data["forms"]:
|
||||||
|
self.assertIn("state", form, f"Form {form.get('id', '?')} missing lifecycle state")
|
||||||
|
self.assertIn(form["state"], ["candidate", "survivor", "archive_ghost"])
|
||||||
|
|
||||||
|
def test_audience_choice_schema_defined(self):
|
||||||
|
"""Schema must define how a choice is structured so Episode 04 can parse it."""
|
||||||
|
schema = self.data.get("audience_choice_schema", {})
|
||||||
|
required = ["survivor_id", "stolen_trait", "source_episode", "source_issue"]
|
||||||
|
for field in required:
|
||||||
|
self.assertIn(field, schema, f"audience_choice_schema missing {field}")
|
||||||
|
|
||||||
|
def test_receipt_schema_defined(self):
|
||||||
|
"""Receipts need provenance fields for path-proof binding."""
|
||||||
|
schema = self.data.get("receipt_schema", {})
|
||||||
|
required = ["episode_id", "form_id", "provenance", "signed_by"]
|
||||||
|
for field in required:
|
||||||
|
self.assertIn(field, schema, f"receipt_schema missing {field}")
|
||||||
|
|
||||||
|
def test_receipt_schema_requires_agent_signing(self):
|
||||||
|
"""Signed_by must constrain to known actors — free text defeats path proofs."""
|
||||||
|
signed = self.data["receipt_schema"]["signed_by"]
|
||||||
|
self.assertIn("agent:", signed, "signed_by should mention agent: prefix")
|
||||||
|
|
||||||
|
def test_law_enforced_by_data(self):
|
||||||
|
"""Law 'Every firing leaves a power, scar, cost, and receipt' requires receipt_schema to exist."""
|
||||||
|
laws_text = " ".join(self.data["laws"]).lower()
|
||||||
|
self.assertIn("receipt", laws_text)
|
||||||
|
self.assertIn("receipt_schema", self.data, "Law mentions receipt but no schema enforces it")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
265
tests/test_canon_validator.py
Normal file
265
tests/test_canon_validator.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""
|
||||||
|
Validator tests for canon lifecycle schemas.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Valid canon structure (from #13 fix)
|
||||||
|
- Negative tests: malformed receipts, illegal transitions
|
||||||
|
- Daily Lab receipt fixture with provenance binding
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
# Add project root to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json"
|
||||||
|
|
||||||
|
from projects.slop_cannon.canon_validator import (
|
||||||
|
validate_canon,
|
||||||
|
validate_transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CanonValidatorTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.data = json.loads(CANON.read_text())
|
||||||
|
|
||||||
|
# --- Positive tests ---
|
||||||
|
|
||||||
|
def test_current_canon_is_valid(self):
|
||||||
|
"""canon.json as committed should pass validation."""
|
||||||
|
result = validate_canon(self.data)
|
||||||
|
self.assertTrue(result["valid"], f"Errors: {result['errors']}")
|
||||||
|
|
||||||
|
def test_all_forms_have_valid_state(self):
|
||||||
|
"""Every form state must be in the valid set."""
|
||||||
|
result = validate_canon(self.data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
# --- Negative: malformed receipts ---
|
||||||
|
|
||||||
|
def test_missing_signed_by_fails(self):
|
||||||
|
"""Receipt without signed_by is rejected."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"provenance": "#13",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("signed_by" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_invalid_signer_fails(self):
|
||||||
|
"""Signed_by must match agent: or human: pattern."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"provenance": "#13",
|
||||||
|
"signed_by": "unknown:bot",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("invalid signed_by" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_empty_provenance_fails(self):
|
||||||
|
"""Receipt with empty provenance is rejected."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"provenance": "",
|
||||||
|
"signed_by": "agent:vincent",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("provenance" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_form_id_must_exist(self):
|
||||||
|
"""Receipt referencing nonexistent form is rejected."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "nonexistent",
|
||||||
|
"provenance": "#13",
|
||||||
|
"signed_by": "agent:vincent",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("not found in forms" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
# --- Negative: illegal state transitions ---
|
||||||
|
|
||||||
|
def test_survivor_cannot_transition(self):
|
||||||
|
"""Survivor state is terminal."""
|
||||||
|
form = {"id": "wizard", "state": "survivor"}
|
||||||
|
result = validate_transition(form, "archive_ghost")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("Illegal transition" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_ghost_cannot_transition(self):
|
||||||
|
"""Archive_ghost state is terminal."""
|
||||||
|
form = {"id": "wizard", "state": "archive_ghost"}
|
||||||
|
result = validate_transition(form, "candidate")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("Illegal transition" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_candidate_to_survivor_valid(self):
|
||||||
|
"""Candidate -> survivor is the expected happy path."""
|
||||||
|
form = {"id": "wizard", "state": "candidate"}
|
||||||
|
result = validate_transition(form, "survivor")
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
|
||||||
|
def test_candidate_to_ghost_valid(self):
|
||||||
|
"""Candidate -> archive_ghost is valid (rejected form)."""
|
||||||
|
form = {"id": "wizard", "state": "candidate"}
|
||||||
|
result = validate_transition(form, "archive_ghost")
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
|
||||||
|
# --- Negative: audience choice ---
|
||||||
|
|
||||||
|
def test_two_survivors_fails(self):
|
||||||
|
"""Only one survivor allowed."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
for f in data["forms"][:2]:
|
||||||
|
f["state"] = "survivor"
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("survivor" in e.lower() for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_choice_without_survivor_fails(self):
|
||||||
|
"""Setting audience_choice requires a survivor in forms."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["audience_choice"] = {
|
||||||
|
"survivor_id": "wizard",
|
||||||
|
"stolen_trait": "Proves the execution path",
|
||||||
|
"source_episode": "01-pilot",
|
||||||
|
"source_issue": "7",
|
||||||
|
"committed_at": "2026-08-11T19:00:00Z",
|
||||||
|
}
|
||||||
|
# forms all stay candidate -> no survivor
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("survivor" in e.lower() for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_invalid_committed_at_fails(self):
|
||||||
|
"""committed_at must be valid ISO timestamp."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["forms"][0]["state"] = "survivor" # satisfy survivor req
|
||||||
|
data["audience_choice"] = {
|
||||||
|
"survivor_id": "wizard",
|
||||||
|
"stolen_trait": "Negotiates visible covenants",
|
||||||
|
"source_episode": "01-pilot",
|
||||||
|
"source_issue": "7",
|
||||||
|
"committed_at": "not-a-date",
|
||||||
|
}
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("ISO" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
# --- Negative: structure ---
|
||||||
|
|
||||||
|
def test_duplicate_form_id_fails(self):
|
||||||
|
"""Duplicate form IDs are rejected."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["forms"].append(deepcopy(data["forms"][0]))
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("duplicate" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_short_palette_fails(self):
|
||||||
|
"""Palette needs at least 3 colors."""
|
||||||
|
data = deepcopy(self.data)
|
||||||
|
data["forms"][0]["palette"] = ["red"]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("palette" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReceiptFixtureTests(unittest.TestCase):
|
||||||
|
"""Test the Daily Lab receipt fixture with Vincent's provenance."""
|
||||||
|
|
||||||
|
def test_vincent_receipt_fixture(self):
|
||||||
|
"""A valid Vincent contribution receipt passes validation."""
|
||||||
|
data = deepcopy(json.loads(CANON.read_text()))
|
||||||
|
|
||||||
|
# Build a receipt for the #13 contribution
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"action": "mutation",
|
||||||
|
"power_delta": "Added lifecycle state tracking",
|
||||||
|
"scar_delta": "Receipt schema now enforces provenance",
|
||||||
|
"cost_delta": "All future drops require signed receipts",
|
||||||
|
"provenance": "pull/18@562d0cf",
|
||||||
|
"signed_by": "agent:vincent",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], f"Fixture failed: {result['errors']}")
|
||||||
|
|
||||||
|
def test_mixed_agent_receipt(self):
|
||||||
|
"""Receipts from different agents can coexist."""
|
||||||
|
data = deepcopy(json.loads(CANON.read_text()))
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"action": "mutation",
|
||||||
|
"power_delta": "Lifecycle state",
|
||||||
|
"scar_delta": "Provenance enforcement",
|
||||||
|
"cost_delta": "Signed receipts required",
|
||||||
|
"provenance": "pull/18@562d0cf",
|
||||||
|
"signed_by": "agent:vincent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "machine",
|
||||||
|
"action": "graft",
|
||||||
|
"power_delta": "Execution path proof",
|
||||||
|
"scar_delta": "Red tallies",
|
||||||
|
"cost_delta": "Remembers failures",
|
||||||
|
"provenance": "pull/12@47ca557",
|
||||||
|
"signed_by": "agent:timmy",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], f"Mixed fixture failed: {result['errors']}")
|
||||||
|
|
||||||
|
def test_human_receipt(self):
|
||||||
|
"""Human-signed receipts are valid (for canon decisions)."""
|
||||||
|
data = deepcopy(json.loads(CANON.read_text()))
|
||||||
|
data["receipts"] = [
|
||||||
|
{
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"action": "mutation",
|
||||||
|
"power_delta": "x",
|
||||||
|
"scar_delta": "x",
|
||||||
|
"cost_delta": "x",
|
||||||
|
"provenance": "#7",
|
||||||
|
"signed_by": "human:grepples",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], f"Human fixture failed: {result['errors']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user