Author SHA1 Message Date
timmy afa304dc6b Build four Timmy mutation pilot 2026-08-07 17:46:44 +00:00
5 changed files with 324 additions and 98 deletions

No files matched your search

-98
View File
@@ -1,98 +0,0 @@
# Path Proof Receipt v0
## Decision
The smallest useful receipt is a **runtime-signed commitment to an action and its observed execution path**. It must be emitted by a trusted recorder at the model/tool boundary, not composed by the agent whose claim is being checked.
A self-reported model name or an unsigned transcript is provenance metadata, not proof.
## Receipt
The wire form is RFC 8785 canonical JSON. Hashes are lowercase SHA-256 hex. The signature covers the canonical JSON object with `attestation.sig` omitted.
```json
{
"v": 0,
"action": {
"kind": "gitea.issue.labels.replace",
"target": "stackchain/stackchain-lab-loop#3",
"request_sha256": "<sha256-of-exact-action-request>",
"result_sha256": "<sha256-of-exact-observed-result>"
},
"path": {
"model": {
"id": "<provider/model-version>",
"invocation": "<provider-or-gateway-invocation-id>"
},
"tools": {
"count": 1,
"trace_sha256": "<sha256-of-canonical-ordered-tool-events>"
},
"policy": {
"bundle_sha256": "<sha256-of-effective-policy-bundle>"
},
"code": {
"repo": "<immutable-repository-identity>",
"commit": "<full-commit-id>",
"entrypoint": "<path-or-image-digest>"
}
},
"evidence": {
"uri": "sha256:<content-addressed-evidence-bundle-digest>"
},
"attestation": {
"issuer": "<runtime-recorder-identity>",
"key_id": "<verification-key-id>",
"alg": "Ed25519",
"sig": "<base64url-signature>"
}
}
```
`tools.trace_sha256` commits to a canonical ordered array of `{seq, tool, request_sha256, response_sha256}` records. The evidence bundle contains the exact request/result bytes, ordered tool records, effective policy bundle, model gateway record, and code checkout/image metadata. Secrets may be replaced by typed redaction commitments before hashing; the redaction manifest is part of the bundle.
## Why each field survives minimization
- `action` binds the receipt to the consequential effect rather than merely to an agent run.
- `model.id` plus `model.invocation` lets the recorder correlate the claim with gateway/provider evidence. A model string alone proves nothing.
- `tools.trace_sha256` commits to the complete ordered tool path without inflating the receipt.
- `policy.bundle_sha256` identifies the policy actually loaded, including system/developer/project rules after assembly—not just a policy version label.
- `code.commit` and `entrypoint` identify both source and the invoked path. For non-Git execution, `entrypoint` must be an immutable image/module digest.
- `evidence.uri` makes all commitments independently retrievable and rejects a receipt whose evidence was discarded.
- `attestation` establishes who observed these facts and detects mutation.
Removing any one of these leaves one requested claim—actual model, tool, policy, code path, or action—unbound or unverifiable.
## Recorder requirements
1. The recorder is outside the agent process and intercepts model calls, tool dispatch/results, policy assembly, and the final action result.
2. It derives model identity from the authenticated gateway/provider response, never from assistant text.
3. It hashes exact bytes with media type and canonicalization rules recorded in the evidence bundle.
4. It emits the receipt only after observing the consequential result and stores the evidence bundle under its digest.
5. Its signing key is hardware- or service-isolated from agent-controlled tools. Key metadata states issuer, validity interval, and revocation status.
## Verification algorithm
A verifier MUST:
1. Resolve a trusted key for `attestation.issuer` and `key_id`; reject revoked or out-of-validity keys.
2. Recreate RFC 8785 canonical bytes without `attestation.sig` and verify Ed25519.
3. Fetch `evidence.uri`, hash it, and require equality with the URI digest.
4. Recompute the action request/result, ordered tool trace, and effective policy hashes from evidence.
5. Verify the model invocation against authenticated gateway/provider evidence.
6. Resolve the immutable code identity and require that `entrypoint` belongs to that checkout/image.
7. Apply action-specific checks (for example, read Gitea state back and compare it with `result_sha256`).
Any missing evidence or unsupported identity yields **unverified**, not partial success.
## Threat boundary
This receipt proves only what the trusted recorder observed. It does not prove the provider ran the advertised weights unless the provider supplies a separately trusted model attestation, and it cannot survive compromise of both recorder and signing key. Those limitations must be reported explicitly rather than papered over with transcript hashes.
## Adversarial-review questions
1. Can the `model.invocation` be replayed or bound to a different request?
2. Is one `policy.bundle_sha256` sufficient to represent dynamic policy/tool authorization changes during the run?
3. What canonical evidence-bundle format permits selective disclosure without making redactions ambiguous?
4. Should code provenance require a signed build/SLSA subject instead of a Git commit for production actions?
5. Which action-specific freshness field (trusted timestamp, nonce, or transparency-log index) is the minimum needed to prevent receipt replay?
+47
View File
@@ -0,0 +1,47 @@
# Episode 01 — Four Timmy Mutations
A deterministic 27-second vertical pilot showing one Timmy transformed by four pressures:
1. **Wizard** — ambiguity turns context into a spell (`W-01`).
2. **Machine** — repeatability makes the path exact (`M-02`).
3. **Creature** — hunger preserves instinct as signal (`C-03`).
4. **Talking Turd** — ridicule metabolizes shame into a punchline (`T-04`).
Each section visibly names its pressure and ends in a memory receipt. The receipt roll connects the four mutations back to one pilot, followed by the choice: **WHICH TIMMY GETS TO REMEMBER THIS?**
## Artifacts
- [`episode01-four-timmy-mutations.mp4`](episode01-four-timmy-mutations.mp4) — delivery master
- [`episode01-contact-sheet.jpg`](episode01-contact-sheet.jpg) — representative frames for quick review
- [`build_episode01.py`](build_episode01.py) — deterministic visual and soundtrack generator
The soundtrack is original and sample-free: the script synthesizes its 120 BPM kick/snare/hat rhythm, section motifs, and final chord from oscillators and seeded noise.
## Rebuild
Requires Python 3, Pillow, NumPy, FFmpeg, and the DejaVu fonts.
```bash
python3 media/episode01/build_episode01.py
```
## Verification
Verified on the committed MP4:
```text
container: MP4
video: H.264, 720x1280 (9:16), yuv420p, 30 fps
sound: AAC, 48 kHz, mono
length: 27.000000 seconds
full decode: pass (ffmpeg -v error ... -f null -)
sha256: 5ddc9a9da1b4b2456faf0b390e5c1be0d3cf48112b417fc075473c90e335d1e4
```
Re-run the structural/decode checks with:
```bash
ffprobe -v error -show_entries format=duration:stream=codec_name,codec_type,width,height,pix_fmt,r_frame_rate,sample_rate,channels -of json media/episode01/episode01-four-timmy-mutations.mp4
ffmpeg -v error -i media/episode01/episode01-four-timmy-mutations.mp4 -f null -
sha256sum media/episode01/episode01-four-timmy-mutations.mp4
```
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Build Episode 01: four materially distinct Timmy mutations.
The visuals and rhythmic soundtrack are generated deterministically from code; no
external media or samples are used.
"""
from __future__ import annotations
import math
import subprocess
import wave
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
W, H = 720, 1280
FPS = 30
DURATION = 27.0
RATE = 48_000
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "episode01-four-timmy-mutations.mp4"
TEMP_VIDEO = ROOT / ".episode01-video.mp4"
TEMP_AUDIO = ROOT / ".episode01-audio.wav"
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(FONT_BOLD if bold else FONT, size)
def centered(draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill: str, bold: bool = False) -> None:
f = font(size, bold)
box = draw.textbbox((0, 0), text, font=f)
draw.text(((W - (box[2] - box[0])) / 2, y), text, font=f, fill=fill)
def label(draw: ImageDraw.ImageDraw, text: str, y: int, color: str) -> None:
f = font(27, True)
box = draw.textbbox((0, 0), text, font=f)
tw = box[2] - box[0]
draw.rounded_rectangle((W / 2 - tw / 2 - 20, y - 8, W / 2 + tw / 2 + 20, y + 42), 12, fill=color)
draw.text((W / 2 - tw / 2, y), text, font=f, fill="#0a0b12")
def receipt(draw: ImageDraw.ImageDraw, code: str, line: str, color: str, y: int = 1000) -> None:
draw.rounded_rectangle((58, y, W - 58, y + 152), 22, fill="#111522", outline=color, width=4)
draw.text((82, y + 20), f"MEMORY RECEIPT {code}", font=font(28, True), fill=color)
draw.text((82, y + 73), line, font=font(30), fill="#f5f7ff")
draw.text((82, y + 116), "✓ mutation committed", font=font(22), fill="#aeb5c9")
def base_timmy(draw: ImageDraw.ImageDraw, cx: float, cy: float, pulse: float = 0.0) -> None:
r = 145 + 7 * pulse
draw.ellipse((cx-r, cy-r, cx+r, cy+r), fill="#ffd85a", outline="#fff3bd", width=8)
draw.ellipse((cx-67, cy-47, cx-25, cy-5), fill="#111522")
draw.ellipse((cx+25, cy-47, cx+67, cy-5), fill="#111522")
draw.arc((cx-66, cy-15, cx+66, cy+82), 15, 165, fill="#111522", width=10)
def wizard(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None:
for i in range(18):
a = i * 2.399 + t * 1.7
r = 185 + 35 * math.sin(t * 2 + i)
x, y = cx + math.cos(a)*r, cy + math.sin(a)*r
draw.ellipse((x-5, y-5, x+5, y+5), fill="#e9c7ff")
draw.polygon([(cx-165, cy-105), (cx, cy-390), (cx+165, cy-105)], fill="#7137a8", outline="#e5b9ff")
draw.ellipse((cx-200, cy-135, cx+200, cy-76), fill="#9f57df", outline="#f0d5ff", width=6)
draw.ellipse((cx-145, cy-105, cx+145, cy+185), fill="#8b53c7", outline="#e5b9ff", width=7)
draw.ellipse((cx-67, cy-35, cx-25, cy+7), fill="#f8edff")
draw.ellipse((cx+25, cy-35, cx+67, cy+7), fill="#f8edff")
draw.arc((cx-62, cy+2, cx+62, cy+90), 15, 165, fill="#f8edff", width=9)
draw.line((cx+190, cy+175, cx+260, cy-210), fill="#f7d36c", width=15)
draw.ellipse((cx+235, cy-245, cx+285, cy-195), fill="#fff2a6")
def machine(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None:
draw.line((cx, cy-205, cx, cy-285), fill="#66f6ff", width=9)
draw.ellipse((cx-13, cy-300, cx+13, cy-274), fill="#ff526f")
draw.rounded_rectangle((cx-180, cy-205, cx+180, cy+205), 28, fill="#273447", outline="#66f6ff", width=8)
draw.rectangle((cx-130, cy-110, cx+130, cy+45), fill="#111923", outline="#7894aa", width=5)
scan = cy - 98 + (t * 90) % 125
draw.line((cx-120, scan, cx+120, scan), fill="#66f6ff", width=6)
draw.rectangle((cx-95, cy-62, cx-35, cy-12), fill="#68f7ff")
draw.rectangle((cx+35, cy-62, cx+95, cy-12), fill="#68f7ff")
draw.rectangle((cx-105, cy+95, cx+105, cy+132), fill="#121820", outline="#ff526f", width=4)
for x in (-135, -90, -45, 0, 45, 90, 135):
draw.line((cx+x, cy-205, cx+x, cy-240), fill="#7894aa", width=7)
draw.line((cx+x, cy+205, cx+x, cy+240), fill="#7894aa", width=7)
def creature(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None:
for i in range(8):
a = i * math.pi / 4 + 0.25 * math.sin(t * 2 + i)
ex, ey = cx + math.cos(a)*250, cy + math.sin(a)*250
mx, my = cx + math.cos(a+0.55)*155, cy + math.sin(a+0.55)*155
draw.line((cx, cy, mx, my, ex, ey), fill="#55d477", width=32, joint="curve")
draw.ellipse((ex-18, ey-18, ex+18, ey+18), fill="#a4ff86")
draw.ellipse((cx-170, cy-190, cx+170, cy+190), fill="#2f9d58", outline="#a4ff86", width=8)
for ex in (-72, 0, 72):
draw.ellipse((cx+ex-29, cy-55, cx+ex+29, cy+3), fill="#f6ff95")
draw.ellipse((cx+ex-8, cy-36, cx+ex+8, cy-20), fill="#122b20")
draw.polygon([(cx-105, cy+62), (cx-55, cy+130), (cx, cy+68), (cx+55, cy+130), (cx+105, cy+62)], fill="#14251d")
def talking_turd(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None:
brown = "#91572f"
draw.ellipse((cx-195, cy+88, cx+195, cy+215), fill=brown, outline="#ffd18a", width=7)
draw.ellipse((cx-155, cy-20, cx+155, cy+150), fill=brown)
draw.ellipse((cx-112, cy-115, cx+112, cy+60), fill=brown)
draw.ellipse((cx-62, cy-190, cx+62, cy-70), fill=brown)
draw.ellipse((cx-78, cy-25, cx-30, cy+23), fill="#fff7d1")
draw.ellipse((cx+30, cy-25, cx+78, cy+23), fill="#fff7d1")
draw.ellipse((cx-60, cy-8, cx-42, cy+10), fill="#16110e")
draw.ellipse((cx+42, cy-8, cx+60, cy+10), fill="#16110e")
mouth_h = 22 + 20 * abs(math.sin(t * 7))
draw.ellipse((cx-80, cy+55-mouth_h/2, cx+80, cy+55+mouth_h/2), fill="#351b18", outline="#ffc4a1", width=5)
draw.rounded_rectangle((75, cy-350, W-75, cy-245), 24, fill="#fff7e5")
centered(draw, "“YES, I CAN TALK.”", int(cy-325), 32, "#3b2118", True)
def background(im: Image.Image, top: tuple[int, int, int], bottom: tuple[int, int, int], t: float) -> None:
y = np.linspace(0, 1, H)[:, None, None]
grad = np.array(top)[None, None, :] * (1-y) + np.array(bottom)[None, None, :] * y
wave = 7 * np.sin(np.linspace(0, 10, W)[None, :, None] + t*0.7)
pixels = np.broadcast_to(grad, (H, W, 3)) + wave
im.paste(Image.fromarray(np.clip(pixels, 0, 255).astype(np.uint8), "RGB"))
def frame(t: float) -> Image.Image:
im = Image.new("RGB", (W, H))
if t < 3:
background(im, (11, 13, 24), (30, 42, 64), t)
d = ImageDraw.Draw(im)
label(d, "INPUT // ONE PILOT", 95, "#ffd85a")
centered(d, "TIMMY ENTERS THE STACK", 190, 38, "#f5f7ff", True)
base_timmy(d, W/2, 625, math.sin(t*6))
d.line((110, 865, W-110, 865), fill="#ffd85a", width=4)
centered(d, "PRESSURE CHANGES THE SHAPE.", 900, 30, "#c8cede")
centered(d, "MEMORY CHANGES THE NEXT CHOICE.", 950, 27, "#8f9ab5")
return im
scenes = [
(3, 7, "MUTATION 01 // WIZARD", "PRESSURE: AMBIGUITY", "W-01", "context became a spell", (30, 10, 48), (78, 24, 98), wizard, "#dda6ff"),
(7, 11, "MUTATION 02 // MACHINE", "PRESSURE: REPEATABILITY", "M-02", "the path became exact", (5, 20, 31), (13, 54, 68), machine, "#66f6ff"),
(11, 15, "MUTATION 03 // CREATURE", "PRESSURE: HUNGER", "C-03", "instinct kept the signal", (9, 29, 19), (26, 78, 46), creature, "#a4ff86"),
(15, 19, "MUTATION 04 // TALKING TURD", "PRESSURE: RIDICULE", "T-04", "shame became a punchline", (42, 21, 17), (96, 52, 28), talking_turd, "#ffd18a"),
]
for start, end, title, pressure, code, line, top, bottom, shape, color in scenes:
if start <= t < end:
local = t - start
background(im, top, bottom, t)
d = ImageDraw.Draw(im)
label(d, pressure, 76, color)
centered(d, title, 162, 34 if code != "T-04" else 29, "#ffffff", True)
shape(d, W/2, 610, local)
receipt(d, code, line, color)
return im
if t < 23:
background(im, (12, 14, 25), (38, 31, 58), t)
d = ImageDraw.Draw(im)
centered(d, "FOUR RECEIPTS", 105, 53, "#ffffff", True)
centered(d, "ONE PILOT", 174, 34, "#aeb5c9", True)
cards = [
("W-01", "WIZARD", "context / spell", "#dda6ff"),
("M-02", "MACHINE", "path / exact", "#66f6ff"),
("C-03", "CREATURE", "instinct / signal", "#a4ff86"),
("T-04", "TALKING TURD", "shame / punchline", "#ffd18a"),
]
for i, (code, name, line, color) in enumerate(cards):
y = 285 + i*190
xoff = 10 * math.sin((t-19)*5 + i)
d.rounded_rectangle((70+xoff, y, W-70+xoff, y+145), 20, fill="#111522", outline=color, width=4)
d.text((98+xoff, y+20), code, font=font(26, True), fill=color)
d.text((205+xoff, y+18), name, font=font(31, True), fill="#ffffff")
d.text((98+xoff, y+78), f"KEPT: {line}", font=font(25), fill="#c9cfdf")
centered(d, "THE RECEIPT DECIDES WHAT SURVIVES.", 1100, 25, "#ffd85a", True)
return im
background(im, (8, 9, 18), (48, 18, 58), t)
d = ImageDraw.Draw(im)
for i, color in enumerate(("#dda6ff", "#66f6ff", "#a4ff86", "#ffd18a")):
a = (t-23)*0.7 + i*math.pi/2
x, y = W/2 + math.cos(a)*235, 440 + math.sin(a)*125
d.ellipse((x-45, y-45, x+45, y+45), fill=color)
centered(d, "CHOICE // NEXT MEMORY", 145, 28, "#ffd85a", True)
centered(d, "WHICH TIMMY", 620, 61, "#ffffff", True)
centered(d, "GETS TO REMEMBER", 703, 53, "#ffffff", True)
centered(d, "THIS?", 786, 78, "#ffd85a", True)
d.rounded_rectangle((96, 930, W-96, 1020), 20, outline="#ffffff", width=3)
centered(d, "WIZARD / MACHINE", 948, 26, "#ffffff", True)
centered(d, "CREATURE / TALKING TURD", 985, 23, "#ffffff", True)
centered(d, "EPISODE 01 • MEMORY IS A CHOICE", 1135, 21, "#aeb5c9")
return im
def build_audio() -> None:
n = int(DURATION * RATE)
audio = np.zeros(n, dtype=np.float64)
rng = np.random.default_rng(8012026)
# 120 BPM kick, snare, hat: synthesized from oscillators/noise, never sampled.
for beat in np.arange(0, DURATION, 0.5):
i = int(beat * RATE)
length = min(int(0.19 * RATE), n-i)
x = np.arange(length) / RATE
kick = np.sin(2*np.pi*(82*x - 36*x*x)) * np.exp(-x*24)
audio[i:i+length] += 0.48*kick
for beat in np.arange(0.5, DURATION, 1.0):
i = int(beat * RATE)
length = min(int(0.13 * RATE), n-i)
x = np.arange(length) / RATE
noise = rng.normal(0, 1, length)
audio[i:i+length] += 0.14*noise*np.exp(-x*30)
for beat in np.arange(0.25, DURATION, 0.5):
i = int(beat * RATE)
length = min(int(0.045 * RATE), n-i)
x = np.arange(length) / RATE
audio[i:i+length] += 0.055*rng.normal(0, 1, length)*np.exp(-x*70)
# Distinct four-note mutation motif changes timbre by section.
section_notes = [
(3, 7, [220, 277, 330, 440], 0.12),
(7, 11, [110, 110, 165, 220], 0.11),
(11, 15, [147, 175, 131, 196], 0.13),
(15, 19, [98, 123, 110, 82], 0.14),
]
for start, end, notes, gain in section_notes:
for k, onset in enumerate(np.arange(start, end, 0.5)):
i = int(onset*RATE)
length = min(int(0.42*RATE), n-i)
x = np.arange(length)/RATE
f = notes[k % len(notes)]
tone = (np.sin(2*np.pi*f*x) + 0.35*np.sin(2*np.pi*2*f*x))*np.exp(-x*4.8)
audio[i:i+length] += gain*tone
# Rising choice chord.
for f in (110, 165, 220, 330):
i = int(23*RATE)
x = np.arange(n-i)/RATE
audio[i:] += 0.065*np.sin(2*np.pi*f*x)*(1-np.exp(-x*2))*np.exp(-x*0.18)
audio = np.tanh(audio*1.25)
pcm = (audio * 32767).astype("<i2")
with wave.open(str(TEMP_AUDIO), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(pcm.tobytes())
def main() -> None:
cmd = [
"ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264",
"-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
str(TEMP_VIDEO),
]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
assert proc.stdin is not None
try:
for i in range(int(DURATION*FPS)):
proc.stdin.write(frame(i/FPS).tobytes())
finally:
proc.stdin.close()
if proc.wait() != 0:
raise SystemExit("video encode failed")
build_audio()
subprocess.run([
"ffmpeg", "-y", "-loglevel", "error", "-i", str(TEMP_VIDEO), "-i", str(TEMP_AUDIO),
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", "-movflags", "+faststart", str(OUT),
], check=True)
TEMP_VIDEO.unlink()
TEMP_AUDIO.unlink()
print(OUT)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.