feat(pet): rotate completed history without resetting the world

Persist immutable replay segments before advancing each active habitat. Version 2
records an exact origin and absolute telemetry sequences, preserving particle,
random-stream and score continuation while retiring consumed history. Add archive
access on browser, Apple and Android and session-local TUI segment files. Keep
unplayed imported events when changing browser sources and reject failed saves
without discarding the running world.

Validation: pet check 42 passed/0 failed; TUI Watch 12 passed/0 failed;
Apple host 10 workflows passed/0 failed; Android device 8 passed/0 failed,
assembleDebug and lintDebug passed; iOS Simulator build and archive export passed.
Product gate: npm test 66 package + 12 SDK + 446 web passed/0 failed;
npm run check:web passed with 2 existing warnings.
30-hour synthetic Still simulation: 263 archives, active file <0.45 MB,
exact restoration after every rotation. Browser archive/reopen and late-segment
seeking verified. This is not animated device, provider or release acceptance.
This commit is contained in:
CodeWhale Bot committed 2026-09-12 11:37:34 -07:00
1 parent 3972a9ca89
commit 5c70db41f0
29 files changed
+988 -263

No files matched your search

+3 -3
View File
@@ -90,7 +90,7 @@ impl WorkspaceFile {
})
}
pub(super) fn sibling(&self, name: &str) -> io::Result<Self> {
pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
return Err(invalid_path());
}
@@ -268,7 +268,7 @@ impl WorkspaceFile {
})
}
pub(super) fn sibling(&self, name: &str) -> io::Result<Self> {
pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
return Err(invalid_path());
}
@@ -508,7 +508,7 @@ impl WorkspaceFile {
"Confined Fleet artifact I/O is unavailable on this platform",
))
}
pub(super) fn sibling(&self, _: &str) -> io::Result<Self> {
pub(crate) fn sibling(&self, _: &str) -> io::Result<Self> {
unreachable!()
}
pub(crate) fn open_update(&self, _: bool, _: bool) -> io::Result<File> {
@@ -72,6 +72,10 @@ impl Store {
}
pub fn save(&mut self, text: &str) -> io::Result<()> {
self.save_archived(text, None)
}
pub fn save_archived(&mut self, text: &str, archive: Option<(&[u8], u64)>) -> io::Result<()> {
if text.len() > MAX_BYTES {
return Err(io::Error::other("Pet habitat exceeds 8 MiB"));
}
@@ -80,6 +84,31 @@ impl Store {
if current != self.expected {
return Err(io::Error::other("Another writer changed the pet habitat"));
}
if let Some((bytes, tick)) = archive {
if bytes.len() > MAX_EXPORT_BYTES {
return Err(io::Error::other("Pet recording exceeds 64 MiB"));
}
let hash: String = Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let archived = self
.data
.sibling(&format!("segment-{tick:012}-{hash}.json"))?;
if let Err(error) = archived.publish(bytes) {
if error.kind() != io::ErrorKind::AlreadyExists {
return Err(error);
}
let mut existing = Vec::new();
archived
.open_file()?
.take(MAX_EXPORT_BYTES as u64 + 1)
.read_to_end(&mut existing)?;
if existing != bytes {
return Err(io::Error::other("An archived recording was changed"));
}
}
}
self.data.replace(text.as_bytes())
})?;
self.expected = Some(Sha256::digest(text.as_bytes()).into());
@@ -150,6 +179,41 @@ mod tests {
);
}
#[test]
fn segments_are_immutable_and_must_publish_before_the_habitat_advances() {
let root = tempfile::tempdir().unwrap();
let mut files = store(root.path());
files.load().unwrap();
files.save("before").unwrap();
files
.save_archived("after", Some((b"complete history", 123)))
.unwrap();
let name = "segment-000000000123-42fcd454bac01f468e693701bf88157cd7a540556f85d26be0009392e43ecbd4.json";
let archive = root.path().join(name);
assert_eq!(std::fs::read(&archive).unwrap(), b"complete history");
std::fs::write(&archive, "damaged archive").unwrap();
assert!(
files
.save_archived("lost", Some((b"complete history", 123)))
.is_err()
);
assert_eq!(
std::fs::read_to_string(root.path().join("habitat.json")).unwrap(),
"after"
);
assert_eq!(
std::fs::read_to_string(&archive).unwrap(),
"damaged archive"
);
std::fs::write(root.path().join("habitat.json"), "another writer").unwrap();
assert!(
files
.save_archived("lost", Some((b"new history", 124)))
.is_err()
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 3);
}
#[cfg(unix)]
#[test]
fn private_atomic_files_reject_links_and_replaced_locks() {
+141 -48
View File
@@ -18,52 +18,44 @@ class PetNative {
world;
engine = new pet_engine_js_1.PetEngineTelemetry();
engineTick = 0;
segment;
constructor(pointsJSON, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion = 2) {
const points = JSON.parse(pointsJSON);
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
throw new Error('Invalid native whale body.');
this.world = new pet_world_js_1.PetWorld(points, live ? (0, pet_telemetry_js_1.compilePetTelemetry)([]) : (0, pet_telemetry_js_1.decodePetJSONL)(tapeJSONL), JSON.parse(interactionsJSON), expressionVersion);
this.world = new pet_world_js_1.PetWorld(points, live ? (0, pet_telemetry_js_1.compilePetTelemetry)([]) : (0, pet_telemetry_js_1.decodePetJSONL)(tapeJSONL), JSON.parse(interactionsJSON), expressionVersion, true);
}
step(dt, motion) { this.world.step(dt, { motion, sensitivity: 1 }); return this.snapshot(); }
snapshot() { return JSON.stringify({ ...this.world.frame, voices: this.world.voices, digest: (0, pet_sim_js_1.digest)(this.world.sim) }); }
interact(kind, x, y) { this.world.interact(kind, x, y); }
interactions() { return JSON.stringify(this.world.interactions); }
accept(packet) { this.world.acceptTelemetry(JSON.parse(packet)); }
recording(withCheckpoint = false) {
return JSON.stringify({ petReplayVersion: 1, expressionVersion: this.world.sim.expressionVersion, tape: this.world.tape,
interactions: this.world.interactions, ...(withCheckpoint ? { checkpoint: this.world.checkpoint() } : {}) });
}
recording(withCheckpoint = false) { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment() { return this.world.needsSegment; }
prepareSegment() { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
commitSegment() { if (!this.segment)
throw new Error('No pet segment was prepared.'); this.segment.commit(); this.segment = undefined; }
checkpoint() { return JSON.stringify(this.world.checkpoint()); }
recordingChunk(index) { return this.world.recordingChunk(index); }
recordingChunk(index, completed = false) { return this.world.recordingChunk(index, completed); }
restoreCheckpoint(text) {
if (text.length > 512 * 1024)
throw new Error('Pet checkpoint exceeds its size limit.');
this.restoreHistory(this.world.tape, this.world.interactions, JSON.parse(text));
this.restoreRecording(JSON.stringify({ ...this.world.recording(false), checkpoint: JSON.parse(text) }));
}
restoreRecording(text) {
if (text.length > 8 * 1024 * 1024)
throw new Error('Native habitat exceeds 8 MiB.');
const r = JSON.parse(text);
if (!r || r.petReplayVersion !== 1 || !Array.isArray(r.tape) || !Array.isArray(r.interactions))
throw new Error('Invalid native habitat.');
if (r.expressionVersion !== undefined && ![1, 2].includes(r.expressionVersion))
throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && (r.expressionVersion ?? 1) !== (r.checkpoint?.sim?.expressionVersion ?? 1))
throw new Error('Pet expression version does not match its checkpoint.');
this.restoreHistory(r.tape, r.interactions, r.checkpoint, r.expressionVersion ?? 1);
}
restoreHistory(tape, interactions, checkpoint, expressionVersion = 2) {
const points = this.world.sim.p.map(p => [p.hx, p.hy]);
this.world = checkpoint === undefined ? new pet_world_js_1.PetWorld(points, tape, interactions, expressionVersion)
: pet_world_js_1.PetWorld.restore(points, tape, interactions, checkpoint);
this.world = pet_world_js_1.PetWorld.fromRecording(points, JSON.parse(text));
this.segment = undefined;
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
// A restored creature does not prove an Engine operation is still active.
this.engine = new pet_engine_js_1.PetEngineTelemetry();
}
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine() {
const end = this.world.tape.length * 12;
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24)
throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) {
@@ -116,12 +108,14 @@ exports.PetNative = PetNative;
factories["pet-world"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetWorld = void 0;
exports.PetWorld = exports.PET_MAX_SECONDS = void 0;
const model_js_1 = require("./model.js");
const pet_sim_js_1 = require("./pet-sim.js");
const pet_telemetry_js_1 = require("./pet-telemetry.js");
const pet_audio_js_1 = require("./pet-audio.js");
const HZ = 30;
var pet_sim_js_2 = require("./pet-sim.js");
Object.defineProperty(exports, "PET_MAX_SECONDS", { enumerable: true, get: function () { return pet_sim_js_2.PET_MAX_SECONDS; } });
const seedFor = (name) => (0xC0FFEE ^ (0, model_js_1.stableHash)(name)) >>> 0;
/** Fixed-tick creature controller. Wall clocks and pointer APIs belong to drivers.
* Reconstructing with the same tape and interactions is also the seek operation.
@@ -156,16 +150,27 @@ class PetWorld {
food = null;
members = new Map();
lastStill = '';
constructor(points, tape = [], interactions = [], expressionVersion = 2) {
origin;
segmented = false;
hasTelemetry = false;
get startTimeMs() { return this.origin?.frame.timeMs ?? 0; }
get endTimeMs() { return Math.max(this.frame.timeMs, (this.tapeLog.at(-1)?.simTimeMs ?? 0) + 400); }
get needsSegment() {
return !this.segmented && this.tapeLog.length < 1024 && this.interactionLog.length < 4096
|| this.bucketIndex >= 1024 || this.interactionIndex >= 4096;
}
constructor(points, tape = [], interactions = [], expressionVersion = 2, segmented = false) {
if (tape.length > 216_000 || interactions.length > 100_000)
throw new Error('Pet recording exceeds its input limit.');
this.sim = new pet_sim_js_1.PetSim(points, 0xC0FFEE, expressionVersion);
this.segmented = segmented;
this.hasTelemetry = tape.length > 0;
this.tapeLog = structuredClone([...tape]);
this.interactionLog = structuredClone([...interactions]);
for (let i = 0; i < this.tape.length; i++) {
const b = this.tape[i];
(0, pet_telemetry_js_1.validatePetBucket)(b);
if (b.version !== 1 || b.sequence !== i || b.simTimeMs !== i * 400 || b.durationMs !== 400)
if (segmented ? i > 0 && b.sequence <= this.tape[i - 1].sequence : b.sequence !== i)
throw new Error('World requires contiguous version 1 pet buckets.');
}
for (let i = 0; i < this.interactionLog.length; i++) {
@@ -178,9 +183,12 @@ class PetWorld {
this.hashTape(0);
this.frame = this.makeFrame(0);
this.voices = this.score.voices(this.frame);
if (segmented)
this.origin = this.checkpoint();
}
checkpoint() {
return structuredClone({ petCheckpointVersion: 1, history: this.historyDigest(),
return structuredClone({ petCheckpointVersion: this.segmented ? 2 : 1,
...(this.segmented ? { historyStart: (this.origin?.tick ?? this.tick), hasTelemetry: this.hasTelemetry } : {}), history: this.historyDigest(),
sim: this.sim.checkpoint(), score: this.score.checkpoint(), accumulator: this.accumulator,
tick: this.tick, bucketIndex: this.bucketIndex, interactionIndex: this.interactionIndex, branchTick: this.branchTick,
random: this.random.state(), behaviour: this.behaviour, until: this.until,
@@ -189,24 +197,92 @@ class PetWorld {
waitSince: this.waitSince, food: this.food, members: [...this.members.values()], lastStill: this.lastStill,
frame: this.frame, voices: this.voices });
}
recording(withCheckpoint = true, completed = false) {
const tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const history = this.historyDigest(tapeEnd, inputEnd);
return { petReplayVersion: this.segmented ? 2 : 1, expressionVersion: this.sim.expressionVersion,
tape: this.tapeLog.slice(0, tapeEnd), interactions: this.interactionLog.slice(0, inputEnd).map(e => ({ ...e })),
...(this.origin ? { start: { ...structuredClone(this.origin), hasTelemetry: this.hasTelemetry, history } } : {}),
...(withCheckpoint ? { checkpoint: { ...this.checkpoint(), history } } : {}) };
}
static fromRecording(points, value) {
const r = value;
if (!r || ![1, 2].includes(r.petReplayVersion) || !Array.isArray(r.tape) || !Array.isArray(r.interactions))
throw new Error('Invalid pet recording.');
const version = r.expressionVersion === undefined ? 1 : r.expressionVersion;
if (![1, 2].includes(version))
throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && !r.checkpoint || r.start !== undefined && !r.start)
throw new Error('Invalid pet checkpoint.');
for (const c of [r.start, r.checkpoint])
if (c && (c.sim?.expressionVersion ?? 1) !== version)
throw new Error('Pet expression version does not match its checkpoint.');
if (r.petReplayVersion === 1 && (r.start || r.checkpoint && r.checkpoint.petCheckpointVersion !== 1))
throw new Error('Invalid legacy pet recording.');
if (r.petReplayVersion === 2 && (!r.start || r.start.petCheckpointVersion !== 2 || r.start.historyStart !== r.start.tick))
throw new Error('The recording segment is missing its starting checkpoint.');
const first = r.start && PetWorld.restore(points, r.tape, r.interactions, r.start);
const world = r.checkpoint ? PetWorld.restore(points, r.tape, r.interactions, r.checkpoint) : first ?? new PetWorld(points, r.tape, r.interactions, version);
if (first) {
if (!world.segmented || world.tick < first.tick || r.checkpoint && r.checkpoint.historyStart !== first.tick)
throw new Error('Pet segment checkpoints do not agree.');
world.origin = first.checkpoint();
}
return world;
}
/** Retire only consumed input. The exact origin makes each archived segment
* independently replayable; no particle, random stream or score is reset. */
prepareSegment() {
const dropTape = Math.max(0, this.bucketIndex), dropInputs = this.interactionIndex, previous = this.origin;
const tape = this.tapeLog.slice(dropTape), interactions = this.interactionLog.slice(dropInputs);
const points = this.sim.p.map(p => [p.hx, p.hy]);
const c = this.checkpoint();
c.petCheckpointVersion = 2;
c.historyStart = this.tick;
c.hasTelemetry = this.hasTelemetry;
c.bucketIndex -= dropTape;
c.interactionIndex = 0;
c.history = new PetWorld(points, tape, interactions, this.sim.expressionVersion, true).historyDigest();
const next = PetWorld.restore(points, tape, interactions, c);
next.origin = next.checkpoint();
let committed = false;
return { recording: next.recording(), archive: this.recording(true, true), commit: () => {
if (committed || this.origin !== previous || this.tick < c.tick)
throw new Error('The pet recording segment has changed.');
this.tapeLog.splice(0, dropTape);
this.interactionLog.splice(0, dropInputs);
this.bucketIndex -= dropTape;
this.interactionIndex -= dropInputs;
this.tapeHashes = [];
this.hashTape(0);
this.segmented = true;
this.origin = next.origin;
committed = true;
} };
}
/** Lossless version 1 export, including the current pose and score. Drivers
* consume all chunks synchronously on the world's owner before another tick.
* Only a small slice is serialized inside an embedded runtime at a time. */
recordingChunk(index) {
recordingChunk(index, completed = false) {
if (!Number.isSafeInteger(index) || index < 0)
throw new Error('Invalid pet export cursor.');
const size = 16, tapes = Math.ceil(this.tapeLog.length / size), inputs = Math.ceil(this.interactionLog.length / size);
const size = 16, tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const tapes = Math.ceil(tapeEnd / size), inputs = Math.ceil(inputEnd / size);
if (index === 0)
return `{"petReplayVersion":1,"expressionVersion":${this.sim.expressionVersion},"tape":[`;
return `{"petReplayVersion":${this.segmented ? 2 : 1},"expressionVersion":${this.sim.expressionVersion},"tape":[`;
if (index <= tapes)
return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, index * size)).slice(1, -1);
return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, Math.min(tapeEnd, index * size))).slice(1, -1);
if (index === tapes + 1)
return '],"interactions":[';
const part = index - tapes - 2;
if (part < inputs)
return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, (part + 1) * size)).slice(1, -1);
if (part === inputs)
return `],"checkpoint":${JSON.stringify(this.checkpoint())}}`;
return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, Math.min(inputEnd, (part + 1) * size))).slice(1, -1);
if (part === inputs) {
const history = this.historyDigest(tapeEnd, inputEnd);
return `]${this.origin ? ',"start":' + JSON.stringify({ ...this.origin, hasTelemetry: this.hasTelemetry, history }) : ''},"checkpoint":${JSON.stringify({ ...this.checkpoint(), history })}}`;
}
return null;
}
/** Prefix states preserve the original FNV checksum byte for byte. Live
@@ -216,9 +292,9 @@ class PetWorld {
for (let i = from; i < this.tapeLog.length; i++)
this.tapeHashes[i] = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? (0, model_js_1.stableHash)('[['));
}
historyDigest() {
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < this.interactionLog.length; i++)
historyDigest(tapeEnd = this.tapeLog.length, inputEnd = this.interactionLog.length) {
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes[tapeEnd - 1] ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < inputEnd; i++)
hash = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return (0, model_js_1.stableHash)(']]', hash);
}
@@ -228,12 +304,13 @@ class PetWorld {
const c = value;
const range = (n, low, high) => Number.isFinite(n) && n >= low && n <= high;
const integer = (n, low, high) => Number.isSafeInteger(n) && range(n, low, high);
if (!c || c.petCheckpointVersion !== 1 || JSON.stringify(c).length > 512 * 1024
|| !integer(c.tick, 0, 2_592_000) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
if (!c || ![1, 2].includes(c.petCheckpointVersion) || JSON.stringify(c).length > 512 * 1024
|| c.petCheckpointVersion === 2 && (!integer(c.historyStart, 0, c.tick) || typeof c.hasTelemetry !== 'boolean')
|| !integer(c.tick, 0, pet_sim_js_1.PET_MAX_SECONDS * HZ) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
|| !integer(c.bucketIndex, -1, tape.length - 1) || !integer(c.interactionIndex, 0, interactions.length)
|| !integer(c.branchTick, -1, c.tick) || !integer(c.random, 0, 0xffffffff)
|| !['swim', 'dive', 'roll', 'breathe', 'drift', 'doze', 'wake'].includes(c.behaviour)
|| !range(c.until, 0, 86_410) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.until, 0, pet_sim_js_1.PET_MAX_SECONDS + 10) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.lit, 0, 1) || !range(c.lastActivity, 0, c.tick / HZ)
|| c.addressedAt !== null && !range(c.addressedAt, 0, c.tick / HZ)
|| !range(c.waitSince, -1, c.tick / HZ) || typeof c.lastStill !== 'string' || c.lastStill.length > 2048
@@ -252,7 +329,11 @@ class PetWorld {
throw new Error('Invalid pet world checkpoint.');
(0, pet_sim_js_1.validatePetState)(c.frame.state);
(0, pet_audio_js_1.renderPetPCM)(c.voices, 0, 0);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1, c.petCheckpointVersion === 2);
if (c.petCheckpointVersion === 2) {
world.hasTelemetry = c.hasTelemetry;
world.origin = structuredClone(c);
}
if (c.history !== world.historyDigest()
|| c.bucketIndex >= 0 && world.tape[c.bucketIndex].simTimeMs > c.frame.timeMs + 1e-7
// acceptTelemetry may fill past gaps after the most recent fixed tick.
@@ -309,8 +390,18 @@ class PetWorld {
acceptTelemetry(input) {
(0, pet_telemetry_js_1.validatePetBucket)(input);
const sequence = Math.floor(this.tick / 12) + 1;
if (this.tapeLog.length >= 216_000)
throw new Error('Archive this pet recording before accepting more telemetry.');
this.hasTelemetry = true;
if (this.segmented) {
const at = this.tapeLog.findIndex(b => b.sequence >= sequence);
const index = at < 0 ? this.tapeLog.length : at;
this.tapeLog.splice(index, at >= 0 && this.tapeLog[at].sequence === sequence ? 1 : 0, { ...structuredClone(input), sequence, simTimeMs: sequence * 400 });
this.hashTape(index);
return;
}
if (sequence >= 216_000)
throw new Error('Start a new pet recording after 24 hours.');
throw new Error('Archive the legacy recording before accepting more telemetry.');
const changedFrom = Math.min(sequence, this.tapeLog.length);
while (this.tapeLog.length <= sequence) {
const at = this.tapeLog.length;
@@ -403,7 +494,7 @@ class PetWorld {
this.y += (this.targetY - this.y) * move;
this.flip += ((Math.abs(dx) < .015 ? this.flip < 0 ? -1 : 1 : dx < 0 ? -1 : 1) - this.flip) * .035;
this.lit += ((sleeping ? .18 : 1) - this.lit) * .03;
const wild = !this.tape.length;
const wild = !this.hasTelemetry;
const state = {
activity: telemetry?.activity ?? (wild ? sleeping ? .05 : .18 : .12),
coherence: telemetry?.coherence ?? (wild ? .94 : .25),
@@ -530,12 +621,13 @@ factories["pet-sim"]=function(exports,require){
// Positions stay normalized in body space (roughly [-0.5, 0.5]²). A renderer
// maps them through layout() to its own medium.
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetSim = exports.CHANNEL_INDEX = exports.CHANNELS = exports.ARCH_OF = exports.REST_STATE = void 0;
exports.PetSim = exports.CHANNEL_INDEX = exports.CHANNELS = exports.ARCH_OF = exports.REST_STATE = exports.PET_MAX_SECONDS = void 0;
exports.validatePetState = validatePetState;
exports.mulberry32 = mulberry32;
exports.layout = layout;
exports.digest = digest;
exports.runTape = runTape;
exports.PET_MAX_SECONDS = 100 * 365 * 86_400;
exports.REST_STATE = {
activity: 0.35, coherence: 0.8, attention: 0, channel: 'reasoning',
observed: 1, roamX: 0, roamY: 0, flip: 1, lit: 1,
@@ -797,8 +889,8 @@ class PetSim {
if (!c || c.version !== 1 || c.expressionVersion !== undefined && ![1, 2].includes(c.expressionVersion) || (c.expressionVersion ?? 1) !== this.expressionVersion || !Array.isArray(c.body) || c.body.length !== this.p.length
|| c.body.some((v, i) => !Array.isArray(v) || v.length !== 3 || v[0] !== this.p[i].hx || v[1] !== this.p[i].hy || v[2] !== this.p[i].s)
|| !Array.isArray(c.particles) || c.particles.length !== this.p.length
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 1_000_000 : 8)))
|| !inRange(c.phase, 0, 100_000) || !inRange(c.clock, 0, 86_400) || !inRange(c.tear, 0, 1)
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 2 * exports.PET_MAX_SECONDS : 8)))
|| !inRange(c.phase, 0, exports.PET_MAX_SECONDS) || !inRange(c.clock, 0, exports.PET_MAX_SECONDS) || !inRange(c.tear, 0, 1)
|| ![c.previous, c.current].every(n => Number.isInteger(n) && n >= 0 && n < exports.CHANNELS.length)
|| !Array.isArray(c.color) || c.color.length !== 3 || c.color.some(n => !inRange(n, 0, 255))
|| !c.frame || ![c.frame.r, c.frame.g, c.frame.b].every(n => inRange(n, 0, 255))
@@ -963,7 +1055,7 @@ exports.PET_BIN_MS = 400;
function validatePetBucket(value) {
(0, pet_sim_js_1.validatePetState)(value);
const b = value;
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0 || b.sequence > pet_sim_js_1.PET_MAX_SECONDS * 2.5
|| b.simTimeMs !== b.sequence * exports.PET_BIN_MS || b.durationMs !== exports.PET_BIN_MS
|| !model_js_1.CATEGORIES.includes(b.channel) || typeof b.waiting !== 'boolean'
|| !Array.isArray(b.agentIds) || b.agentIds.length > 250_000 || b.agentIds.some(id => typeof id !== 'string' || !id || id.length > 4096)
@@ -1026,7 +1118,7 @@ function compilePetTelemetry(input, durationMs = 0, firstSequence = 0, originMs
if (failures.length)
lastOnset = Math.max(lastOnset, failures[failures.length - 1]);
const count = Math.max(1, Math.ceil(durationMs / exports.PET_BIN_MS), Math.floor(lastOnset / exports.PET_BIN_MS) + 1);
if (count > 216_000)
if (count - firstSequence > 216_000 || count > pet_sim_js_1.PET_MAX_SECONDS * 2.5)
throw new Error('Pet replay exceeds 24 hours; select a shorter trace.');
const index = new signal_js_1.IntervalIndex(events), result = [];
const recent = [], names = new Map();
@@ -1387,7 +1479,7 @@ class PetScore {
checkpoint() { return [this.lastWindow, this.lastSequence, this.lastAddress]; }
restore(value) {
if (!Array.isArray(value) || value.length !== 3
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= 216_000) || typeof value[2] !== 'boolean')
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= pet_sim_js_1.PET_MAX_SECONDS * 2.5) || typeof value[2] !== 'boolean')
throw new Error('Invalid pet score checkpoint.');
[this.lastWindow, this.lastSequence, this.lastAddress] = value;
}
@@ -1478,6 +1570,7 @@ factories["pet-engine"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetEngineTelemetry = void 0;
const pet_sim_js_1 = require("./pet-sim.js");
const codewhale_js_1 = require("./codewhale.js");
const pet_telemetry_js_1 = require("./pet-telemetry.js");
/** Read-only adapter for codewhale_protocol::EventMsg metadata. The foreground
@@ -1511,7 +1604,7 @@ class PetEngineTelemetry {
e.endTime = at;
}
observe(value, at) {
if (!Number.isFinite(at) || at < this.lastTime || at > 86_400_000)
if (!Number.isFinite(at) || at < this.lastTime || at > pet_sim_js_1.PET_MAX_SECONDS * 1000)
throw new Error('Invalid Engine pet clock.');
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Invalid Engine pet metadata.');
+24 -7
View File
@@ -72,11 +72,11 @@ pub struct Worker {
/// One command owns the world until export completes. Keep the large buffer in
/// the host, outside QuickJS's 64 MiB heap, and retain the exact checkpoint.
fn export_recording(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<Vec<u8>> {
fn export_recording(ctx: &rquickjs::Ctx<'_>, completed: bool) -> rquickjs::Result<Vec<u8>> {
let mut bytes = Vec::new();
let mut index = 0usize;
loop {
let chunk: Option<String> = ctx.eval(format!("pet.recordingChunk({index})"))?;
let chunk: Option<String> = ctx.eval(format!("pet.recordingChunk({index},{completed})"))?;
let Some(chunk) = chunk else { return Ok(bytes) };
if bytes.len() + chunk.len() > persistence::MAX_EXPORT_BYTES {
return Err(rquickjs::Error::Unknown);
@@ -273,7 +273,7 @@ fn run(
let result = session
.ok_or_else(|| std::io::Error::other("No saved session"))
.and_then(|id| {
let bytes = export_recording(&ctx).map_err(|_| {
let bytes = export_recording(&ctx, false).map_err(|_| {
let _ = ctx.catch();
std::io::Error::other("Pet recording could not be exported")
})?;
@@ -367,10 +367,27 @@ fn save(
notices: &mpsc::SyncSender<Notice>,
) -> Result<(), ()> {
if let Some(files) = store {
let text: String = context
.with(|ctx| ctx.eval("pet.recording(true)"))
.map_err(|_| ())?;
if files.save(&text).is_err() {
let saved = context.with(|ctx| -> rquickjs::Result<()> {
let segment: Option<String> =
ctx.eval("pet.needsSegment() ? pet.prepareSegment() : null")?;
if let Some(text) = segment {
let archive = export_recording(&ctx, true)?;
let tick: u64 =
ctx.eval("Math.round(JSON.parse(pet.snapshot()).timeMs * 30 / 1000)")?;
files
.save_archived(&text, Some((&archive, tick)))
.map_err(|_| rquickjs::Error::Unknown)?;
ctx.eval::<(), _>("pet.commitSegment()")?;
} else {
let text: String = ctx.eval("pet.recording(true)")?;
files.save(&text).map_err(|_| rquickjs::Error::Unknown)?;
}
Ok(())
});
if saved.is_err() {
context.with(|ctx| {
let _ = ctx.catch();
});
*store = None;
let _ = notices.try_send(Notice::StorageUnavailable);
}
+25 -8
View File
@@ -17,10 +17,11 @@ npm test
npm run check:web
```
The pet suite currently contains 40 tests: event occupancy/unknown coverage,
The pet suite currently contains 42 tests: event occupancy/unknown coverage,
late failures, human request pairing, read-only local SSE reconnect/cursor
recovery and cancellation after garbage collection, deterministic world/score/PCM,
checkpoint integrity and continuation, expression-version validation and legacy replay.
checkpoint integrity and continuation, immutable segment boundaries, pending input
retention, expression-version validation and legacy replay.
The standalone verifier compares 380 checkpoints across baseline and edge tapes,
each animated and still, under expression versions 1 and 2. Version 1 must also
match pinned pre-transformation golden digests. Omission of Swift is explicit in its output.
@@ -61,7 +62,8 @@ Apple requires macOS, Xcode command-line tools, and XcodeGen for iOS. The iOS
script generates and builds the Simulator project directly from the shared
source, without first building macOS. Open `pet/ios/CodewhalePet.xcodeproj` to run.
`check-apple.sh` compiles the actual host and runs checkpoint/storage/recovery
workflows, including a generated two-hour synthetic unknown recording. It writes
workflows, including a generated two-hour synthetic unknown recording, segment
publication and a save conflict that must preserve the running world. It writes
only disposable fixtures under ignored conformance results and temporary storage.
Kotlin 2.3.0 can instead be on PATH; its verifier builds only the particle core
and conformance runner. The actual Compose application is a separate Gradle build:
@@ -73,8 +75,8 @@ cd pet/android
```
Use JDK 17 and Android SDK 35; connect a device/emulator for the second command.
The seven instrumentation tests run the real QuickJS binding, Kotlin renderer,
PCM cursor, storage/recovery and Compose lifecycle. See [Android](android/README.md).
The eight instrumentation tests run the real QuickJS binding, Kotlin renderer,
PCM cursor, storage/recovery, immutable segments and Compose lifecycle. See [Android](android/README.md).
## Review direction
@@ -87,7 +89,7 @@ open junctions; the same particle identities return to the whale at rest.
## Local evidence before publication (2026-09-12)
- Packaged pet: 40 tests passed; existing Whalesong consumers: 306 passed after
- Packaged pet: 42 tests passed; existing Whalesong consumers: 306 passed after
canonical source relocation. These are overlapping suites, not additive coverage.
- Product Node gate: 66 package, 12 SDK and 446 web tests passed; production web
check subsequently passed with the GitHub release fetch available.
@@ -99,9 +101,9 @@ open junctions; the same particle identities return to the whale at rest.
tests with one outdated Watch-tab golden; that header was corrected. Subsequent
hosted product CI on `e69e99b` passed Linux, macOS and Windows tests, Rust lint
and the applicable safety/security checks. Each later commit needs its own verdict.
- Apple: nine checkpoint workflows passed; iOS Simulator exercised two-hour
- Apple: ten checkpoint workflows passed; iOS Simulator exercised two-hour
restoration and visible corruption recovery without overwriting the damaged file.
- Android: debug APK and lint build pass; seven instrumentation tests pass on
- Android: debug APK and lint build pass; eight instrumentation tests pass on
Android 15 ARM64, including 4,800 frames, checkpoint continuation, exact PCM,
lifecycle and storage recovery. The emulator audio sink runs without host
speaker output; this does not establish physical listening or power quality.
@@ -110,6 +112,21 @@ open junctions; the same particle identities return to the whale at rest.
interactions from its real 64 MiB QuickJS heap after the 8 MiB autosave path
rejects them. Apple save-conflict recovery preserves the live core, selected
source and preference; its exported pose/score restores exactly.
- Segment continuation: 3,240,000 fixed ticks (30 synthetic hours in Still) produced
263 immutable archives totaling 163.1 MB. Every rotation restored an exact
checkpoint; the active file was at most 0.441 MB at sampled saves. Hourly
post-GC Node heap samples ranged from 7.89 to 8.59 MB. This is shared-core
simulation, not a continuous native-device, provider or listening test.
- Native segments: 12 TUI Watch tests pass, including immutable archive ordering
and stale-writer rejection. Apple's generated two-hour recording archives
5.64 MB and continues from a 0.31 MB active habitat; a failed save keeps the
live world intact. Android rotates 4,096 applied inputs, rejects a competing
writer, restores exact continuation and detects a changed archive.
- Browser: changing sources and reopening an earlier imported recording restores
its pose and full future timeline. Earlier recordings remain available after
reload. A 30-hour segment seeks from its retained origin through its final
checkpoint with Still enabled. iOS Simulator exports an earlier recording
through Files and reports a successful save.
- Authenticated read-only attachment to an existing local Runtime 0.9.13 session
journal passed. An 18-second recorder run produced 45 contiguous unknown buckets
and stopped cleanly in 41 ms after Ctrl+C, without treating historical work as
+29 -10
View File
@@ -141,14 +141,33 @@ starts unknown and drops old human requests. Apple legacy wild preferences
migrate only after a successful checkpoint save. Native autosave is every five
seconds, so a crash may lose work since the last successful save.
New worlds use version 2 recordings with an exact starting checkpoint and
absolute telemetry sequences. After 1,024 consumed buckets or 4,096 applied
inputs, the host saves completed history as an immutable segment before replacing
the active habitat. Only then does the running world retire that history. Its
particles, random streams and score continue without a reset. Gaps remain unknown;
future imported events stay in the active segment. Version 1 imports still work.
Earlier recordings opens saved segments in the browser and exports them on Apple
and Android. TUI segments are JSON files beside `habitat.json` in the session's
`artifacts/pet` directory. Each segment replays independently from its starting
checkpoint; seeking cannot precede that point. Browser source changes archive
the entire outgoing recording, including unplayed imported events. Archived files
are retained, so disk use grows with recorded history even though active history
is bounded.
Native autosaves and native imports are limited to 8 MiB; the QuickJS worker
has a 64 MiB memory limit. All four hosts export the same version 1 recording
in chunks, including the exact checkpoint, with a 64 MiB file limit. Files over
8 MiB can be recovered in the browser. Android finishes the export in a private
staging file before opening the user-selected destination. Apple source changes
keep the current visit when saving fails; leaving without saving requires an
explicit choice. iOS and macOS both offer Save recording. Two-hour restoration has been exercised, but serializing full long tapes
still costs seconds in QuickJS. History rotation and long-session performance
remain open. Physical Android device acceptance, macOS popover inspection,
active-session companion acceptance, content-driven forms and final listening/power quality
are unfinished. This branch is a reviewable prototype, not a release candidate.
has a 64 MiB memory limit. All four hosts export recordings in chunks, including
the exact checkpoint, with a 64 MiB file limit. Files over 8 MiB can be recovered
in the browser. Android finishes the export in a private staging file before
opening the user-selected destination. Apple source changes keep the current
visit when saving fails; leaving without saving requires an explicit choice.
A 30-hour synthetic Still run of the shared core rotated 263 segments and kept
the active file below 0.45 MB, with exact checkpoint continuation after every
rotation. Apple and Android separately exercise archive publication, failed saves
and continued execution in their actual embedded engines. This is not 30 hours
of animated native-device or power testing. Physical Android device acceptance,
macOS popover inspection, active-session companion acceptance and final
listening/power quality remain unfinished. This branch is a reviewable prototype,
not a release candidate.
+8 -4
View File
@@ -23,9 +23,9 @@ Android 15/API 35 ARM64 emulator. This does not establish physical-device sound,
battery use, or acceptance on all supported Android versions.
Wild is a simulated creature. Event demo is synthetic telemetry. More → Import
recording opens the same `petReplayVersion: 1` exports as the other hosts.
recording opens the same version 1 and 2 exports as the other hosts.
Checkpoint-bearing recordings resume their exact world; exports without a
checkpoint replay from the beginning. Missing expression versions retain v1.
checkpoint replay from the segment's start (or time zero for version 1). Missing expression versions retain v1.
Both kinds can be exported again. Autosave and native import are bounded to
8 MiB. Export includes the current checkpoint and writes small chunks through a
private staging file, up to 64 MiB; larger-than-autosave files open in the browser. There is no live companion connection yet.
@@ -41,6 +41,9 @@ Each mode has a separate private, atomic recording, saved every five seconds
and on suspension. Revision checks reject competing writers. Invalid files are
retained. More → Start fresh habitat preserves the previous file as a recovery
copy; More → Export previous world makes that copy available outside the app.
Completed history rotates into immutable segments before the active file advances.
More → Earlier recordings exports those segments; each includes its own starting
checkpoint. Active memory stays bounded while archived history grows in storage.
QuickJS is provided by `app.cash.zipline:zipline:1.27.0`. It runs on one worker,
with a 64 MiB heap and evaluation deadlines. Two standard ES2022 method shims
@@ -48,9 +51,10 @@ cover that binding's older runtime. There are no Java host bindings or remote
script loads. Gradle packages `../ios/Resources/pet-native.js` and its demo
directly; run `npm --prefix pet run sync` after changing the canonical core.
Seven instrumentation tests exercise the real embedded engine, 4,800 shared
Eight instrumentation tests exercise the real embedded engine, 4,800 shared
world frames and Kotlin digests, 3,000 additional checkpoint continuation
frames, version/import boundaries, sample-exact PCM, atomic storage/recovery,
the Compose pause/still/audio/background lifecycle, and recovery export after
a store conflict or with 90,000 pending interactions beyond the autosave limit. The separate
a store conflict or with 90,000 pending interactions beyond the autosave limit.
They also check segment publication, exact continuation and archive corruption. The separate
`verify.sh` retains all 380 pure Kotlin conformance checkpoints.
@@ -60,8 +60,12 @@ class PetNativeTest {
}
// At t=0 both expression versions have the identical home body.
val legacy = JSONObject(before).apply {
put("petReplayVersion", 1); remove("start")
remove("expressionVersion")
getJSONObject("checkpoint").getJSONObject("sim").remove("expressionVersion")
getJSONObject("checkpoint").apply {
put("petCheckpointVersion", 1); remove("historyStart"); remove("hasTelemetry")
getJSONObject("sim").remove("expressionVersion")
}
}
core(legacy.toString()).use { restored ->
assertEquals(1, restored.sim.expressionVersion)
@@ -173,4 +177,38 @@ class PetNativeTest {
assertEquals("second", a.read())
} finally { directory.deleteRecursively() }
}
@Test fun rotationArchivesBeforeRetiringHistoryAndKeepsTheWorldOnAStorageConflict() {
val directory = File(context.cacheDir, "pet-segment-${java.util.UUID.randomUUID()}").apply { mkdirs() }
val seeded = asset("pet-native.js") + "\nconst SegmentBase = PetNative; PetNative = class extends SegmentBase { constructor(...args) { super(...args); for(let i=0;i<4096;i++) this.interact('food', .2, -.15); } };"
try {
PetNativeCore(seeded, asset("whale-points.tsv")).use { world ->
world.tick(false)
val original = world.recording(); val before = world.scene()
val next = world.prepareSegment()!!
val staged = File(directory, "staged.json")
staged.outputStream().use { world.exportRecording(it, completed = true) }
val store = PetHabitatStore(directory, "wild")
assertNull(store.read()); store.save(original)
val habitat = File(directory, "pet-wild.json")
habitat.writeText("another writer")
assertThrows(IllegalStateException::class.java) { store.save(next, staged, 1) }
assertEquals(original, world.recording()); assertTrue(store.archives().isEmpty())
assertEquals("another writer", habitat.readText())
habitat.writeText(original)
store.save(next, staged, 1); world.commitSegment()
assertEquals(before.digest, world.scene().digest)
assertTrue(world.recording().length < original.length)
assertEquals(1, store.archives().size)
assertEquals(original, store.archive(store.archives().single()).readText())
core(store.read()).use { restored ->
assertEquals(before.digest, restored.scene().digest)
repeat(60) { assertEquals(world.tick(true).digest, restored.tick(true).digest) }
}
val archive = store.archive(store.archives().single()); archive.writeText("damaged")
assertThrows(IllegalStateException::class.java) { store.archive(store.archives().single()) }
assertEquals("damaged", archive.readText())
}
} finally { directory.deleteRecursively() }
}
}
@@ -18,10 +18,13 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -69,6 +72,22 @@ private fun PetScreen(model: PetViewModel) {
val import = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::importRecording) }
val export = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecording) }
val recovery = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecovery) }
var selectedArchive by rememberSaveable { mutableStateOf<String?>(null) }
val archived = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
val name = selectedArchive
if (uri != null && name != null) model.exportArchive(uri, name)
selectedArchive = null
}
var showArchives by remember { mutableStateOf(false) }
if (showArchives) AlertDialog(onDismissRequest = { showArchives = false }, title = { Text("Earlier recordings") },
text = { LazyColumn(Modifier.heightIn(max = 320.dp)) {
items(ui.archives, key = { it }) { name ->
val seconds = (name.split('-').dropLast(1).lastOrNull()?.toLongOrNull() ?: 0) / 30
TextButton(onClick = { selectedArchive = name; showArchives = false; archived.launch("codewhale-pet-earlier.json") }) {
Text("Through ${seconds / 3600}:${"%02d".format(seconds / 60 % 60)}:${"%02d".format(seconds % 60)}")
}
}
} }, confirmButton = { TextButton(onClick = { showArchives = false }) { Text("Close") } })
var restart by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(false) }
if (restart) AlertDialog(onDismissRequest = { restart = false }, title = { Text("Start a fresh habitat?") },
@@ -88,6 +107,7 @@ private fun PetScreen(model: PetViewModel) {
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Import recording") }, onClick = { menu = false; import.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) })
DropdownMenuItem(text = { Text("Export recording") }, enabled = ui.scene != null, onClick = { menu = false; export.launch("codewhale-pet.json") })
DropdownMenuItem(text = { Text("Earlier recordings") }, enabled = ui.archives.isNotEmpty(), onClick = { menu = false; showArchives = true })
DropdownMenuItem(text = { Text("Reopen saved habitat") }, onClick = { menu = false; reload = true })
DropdownMenuItem(text = { Text("Start fresh habitat") }, enabled = ui.scene != null, onClick = { menu = false; restart = true })
DropdownMenuItem(text = { Text("Export previous world") }, enabled = ui.canExportRecovery,
@@ -10,7 +10,7 @@ import java.security.MessageDigest
/** Private, bounded, atomic recordings. The revision check also protects a
* second window/process from silently overwriting a newer habitat. */
class PetHabitatStore(directory: File, name: String) {
class PetHabitatStore(private val directory: File, private val name: String) {
init { require(name in setOf("wild", "demo", "recording")) }
private val file = AtomicFile(File(directory, "pet-$name.json"))
private val lockFile = File(directory, "pet-$name.lock")
@@ -21,10 +21,22 @@ class PetHabitatStore(directory: File, name: String) {
if (revision == null) null else file.openRead().use { boundedRead(it) }
}
fun save(text: String) = locked {
fun save(text: String, archive: File? = null, tick: Long = 0) = locked {
val bytes = text.toByteArray(Charsets.UTF_8)
require(bytes.size <= PetNativeCore.MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB." }
check(currentRevision() == revision) { "Another window changed this habitat. Reopen it before saving." }
if (archive != null) {
require(archive.length() <= 64L * 1024 * 1024 && tick >= 0)
val digest = archive.inputStream().use(::hashInput)
val target = File(directory, "pet-$name-segment-${"%012d".format(java.util.Locale.ROOT, tick)}-$digest.json")
if (target.exists()) check(target.inputStream().use(::hashInput) == digest) { "An archived recording was changed." }
else {
val atomic = AtomicFile(target)
val output = atomic.startWrite()
try { archive.inputStream().use { it.copyTo(output) }; atomic.finishWrite(output) }
catch (e: Throwable) { atomic.failWrite(output); throw e }
}
}
write(bytes)
revision = hash(text)
}
@@ -45,6 +57,22 @@ class PetHabitatStore(directory: File, name: String) {
backup
}
fun archives(): List<String> = directory.list()?.filter { validArchive(it) }?.sortedDescending() ?: emptyList()
fun archive(key: String): File {
require(validArchive(key)) { "Invalid recording archive." }
val saved = File(directory, key)
require(saved.isFile && saved.length() <= 64L * 1024 * 1024) { "This recording is unavailable." }
val digest = saved.inputStream().use(::hashInput)
check(key.endsWith("-$digest.json")) { "The archived recording was changed. Its file was kept." }
return saved
}
private fun validArchive(key: String) = key.matches(Regex("pet-$name-segment-[0-9]{12}-[a-f0-9]{64}\\.json"))
private fun hashInput(input: InputStream): String {
val digest = MessageDigest.getInstance("SHA-256"); val buffer = ByteArray(8192)
while (true) { val count = input.read(buffer); if (count < 0) break; digest.update(buffer, 0, count) }
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun currentRevision(): String? {
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
return file.openRead().use { input ->
@@ -90,13 +90,18 @@ class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved
fun recording(): String = string("pet.recording(true)").also {
require(it.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB; export the recording." }
}
fun prepareSegment(): String? {
if (evaluate("pet.needsSegment()") != true) return null
return string("pet.prepareSegment()").also { require(it.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "The active recording exceeds 8 MiB." } }
}
fun commitSegment() { evaluate("pet.commitSegment()") }
/** Called synchronously on the world worker, so the chunks share one clock.
* A larger recovery export never materializes one giant string in QuickJS. */
fun exportRecording(output: OutputStream): Long {
fun exportRecording(output: OutputStream, completed: Boolean = false): Long {
var size = 0L
var index = 0
while (true) {
val value = evaluate("pet.recordingChunk(${index++})") ?: return size
val value = evaluate("pet.recordingChunk(${index++}, $completed)") ?: return size
val bytes = (value as? String ?: error("Invalid pet export chunk.")).toByteArray(Charsets.UTF_8)
check(size + bytes.size <= 64L * 1024 * 1024) { "Recording exceeds the 64 MiB export limit. The current world was kept." }
output.write(bytes); size += bytes.size
@@ -28,7 +28,7 @@ enum class PetMode(val key: String, val label: String, val detail: String) {
data class PetUiState(val scene: PetScene? = null, val mode: PetMode = PetMode.WILD,
val paused: Boolean = false, val sound: Boolean = false, val still: Boolean = false,
val systemStill: Boolean = false, val message: String? = null, val savedAtMs: Double? = null,
val canExportRecovery: Boolean = false, val running: Boolean = false)
val archives: List<String> = emptyList(), val canExportRecovery: Boolean = false, val running: Boolean = false)
/** A single actor owns core, audio cursor and save revision. Compose receives
* immutable projections and never reads a particle while it is being stepped. */
@@ -39,6 +39,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
data class Import(val uri: Uri) : Command
data class Export(val uri: Uri) : Command
data class ExportRecovery(val uri: Uri) : Command
data class ExportArchive(val uri: Uri, val name: String) : Command
data object Restart : Command
data object Reload : Command
}
@@ -141,6 +142,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
fun importRecording(uri: Uri) = enqueue(Command.Import(uri))
fun exportRecording(uri: Uri) = enqueue(Command.Export(uri))
fun exportRecovery(uri: Uri) = enqueue(Command.ExportRecovery(uri))
fun exportArchive(uri: Uri, name: String) = enqueue(Command.ExportArchive(uri, name))
fun restart() = enqueue(Command.Restart)
fun reload() = enqueue(Command.Reload)
fun dismissMessage() { mutable.update { it.copy(message = null) } }
@@ -155,8 +157,17 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
if (saveBlocked) return false
val pet = core ?: return true
try {
checkNotNull(store) { "Habitat storage is unavailable." }.save(pet.recording())
mutable.update { it.copy(savedAtMs = pet.timeMs) }
val files = checkNotNull(store) { "Habitat storage is unavailable." }
val next = pet.prepareSegment()
if (next != null) {
val staged = File.createTempFile("pet-segment-", ".json", getApplication<Application>().cacheDir)
try {
staged.outputStream().buffered().use { pet.exportRecording(it, completed = true) }
files.save(next, staged, kotlin.math.round(pet.timeMs * 30 / 1000).toLong())
pet.commitSegment()
} finally { staged.delete() }
} else files.save(pet.recording())
mutable.update { it.copy(savedAtMs = pet.timeMs, archives = if (next != null) files.archives() else it.archives) }
return true
} catch (e: Exception) {
saveBlocked = true
@@ -176,7 +187,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
stopAudio(); core?.close(); core = next; store = nextStore; saveBlocked = blocked; mode = nextMode
prefs.edit().putString("mode", nextMode.key).apply()
mutable.update { it.copy(scene = next.scene(), mode = nextMode, savedAtMs = if (saved != null && !blocked) next.timeMs else null,
canExportRecovery = recoveryFile() != null) }
canExportRecovery = recoveryFile() != null, archives = nextStore.archives()) }
lastSave = SystemClock.elapsedRealtime()
}
private fun handle(command: Command) {
@@ -195,7 +206,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
val backup = checkNotNull(store).restart(next.recording())
if (backup != null) prefs.edit().putString("recovery-${mode.key}", backup.name).apply()
stopAudio(); core?.close(); core = next; saveBlocked = false
mutable.update { it.copy(scene = next.scene(), savedAtMs = 0.0, canExportRecovery = recoveryFile() != null,
mutable.update { it.copy(scene = next.scene(), savedAtMs = next.timeMs, canExportRecovery = recoveryFile() != null,
message = "Fresh habitat started. You can export the previous saved world from More.") }
lastSave = SystemClock.elapsedRealtime()
} catch (e: Exception) { next.close(); throw e }
@@ -229,6 +240,13 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
else "Recording exported, including the current world state.") }
} finally { staged.delete() }
}
is Command.ExportArchive -> {
val file = checkNotNull(store).archive(command.name)
val out = getApplication<Application>().contentResolver.openOutputStream(command.uri, "wt")
?: error("Could not open the selected export file.")
out.use { output -> file.inputStream().use { it.copyTo(output) } }
mutable.update { it.copy(message = "Earlier recording exported.") }
}
is Command.ExportRecovery -> {
val file = recoveryFile() ?: error("No previous world is available.")
val out = getApplication<Application>().contentResolver.openOutputStream(command.uri, "wt")
+9 -1
View File
@@ -21,6 +21,14 @@ import UniformTypeIdentifiers
if !host.selectSource(next) { pendingSource = next; confirmLeave = true }
})) { ForEach(PetSource.allCases) { Text($0.label).tag($0) } }.pickerStyle(.segmented)
Toggle("Still", isOn: $host.still)
Menu("Earlier recordings") {
ForEach(host.archives, id: \.self) { name in
Button(petArchiveLabel(name)) {
do { recording = PetRecordingDocument(data: try host.archivedRecording(name)); exporting = true }
catch { notice = error.localizedDescription }
}
}
}.disabled(host.archives.isEmpty)
Button("Save recording…") {
do { recording = PetRecordingDocument(data: try host.recordingForExport()); exporting = true }
catch { notice = error.localizedDescription }
@@ -37,7 +45,7 @@ import UniformTypeIdentifiers
.onChange(of: phase) { _, value in host.suspend(value != .active) }
.fileExporter(isPresented: $exporting, document: recording, contentType: .json, defaultFilename: "codewhale-pet.json") { result in
switch result {
case .success: notice = "Recording saved, including this world’s current state. Files over 8 MiB open in the browser."
case .success: notice = "Recording saved. Files over 8 MiB open in the browser."
case .failure(let error): notice = error.localizedDescription
}
recording = nil
+141 -48
View File
@@ -18,52 +18,44 @@ class PetNative {
world;
engine = new pet_engine_js_1.PetEngineTelemetry();
engineTick = 0;
segment;
constructor(pointsJSON, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion = 2) {
const points = JSON.parse(pointsJSON);
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
throw new Error('Invalid native whale body.');
this.world = new pet_world_js_1.PetWorld(points, live ? (0, pet_telemetry_js_1.compilePetTelemetry)([]) : (0, pet_telemetry_js_1.decodePetJSONL)(tapeJSONL), JSON.parse(interactionsJSON), expressionVersion);
this.world = new pet_world_js_1.PetWorld(points, live ? (0, pet_telemetry_js_1.compilePetTelemetry)([]) : (0, pet_telemetry_js_1.decodePetJSONL)(tapeJSONL), JSON.parse(interactionsJSON), expressionVersion, true);
}
step(dt, motion) { this.world.step(dt, { motion, sensitivity: 1 }); return this.snapshot(); }
snapshot() { return JSON.stringify({ ...this.world.frame, voices: this.world.voices, digest: (0, pet_sim_js_1.digest)(this.world.sim) }); }
interact(kind, x, y) { this.world.interact(kind, x, y); }
interactions() { return JSON.stringify(this.world.interactions); }
accept(packet) { this.world.acceptTelemetry(JSON.parse(packet)); }
recording(withCheckpoint = false) {
return JSON.stringify({ petReplayVersion: 1, expressionVersion: this.world.sim.expressionVersion, tape: this.world.tape,
interactions: this.world.interactions, ...(withCheckpoint ? { checkpoint: this.world.checkpoint() } : {}) });
}
recording(withCheckpoint = false) { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment() { return this.world.needsSegment; }
prepareSegment() { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
commitSegment() { if (!this.segment)
throw new Error('No pet segment was prepared.'); this.segment.commit(); this.segment = undefined; }
checkpoint() { return JSON.stringify(this.world.checkpoint()); }
recordingChunk(index) { return this.world.recordingChunk(index); }
recordingChunk(index, completed = false) { return this.world.recordingChunk(index, completed); }
restoreCheckpoint(text) {
if (text.length > 512 * 1024)
throw new Error('Pet checkpoint exceeds its size limit.');
this.restoreHistory(this.world.tape, this.world.interactions, JSON.parse(text));
this.restoreRecording(JSON.stringify({ ...this.world.recording(false), checkpoint: JSON.parse(text) }));
}
restoreRecording(text) {
if (text.length > 8 * 1024 * 1024)
throw new Error('Native habitat exceeds 8 MiB.');
const r = JSON.parse(text);
if (!r || r.petReplayVersion !== 1 || !Array.isArray(r.tape) || !Array.isArray(r.interactions))
throw new Error('Invalid native habitat.');
if (r.expressionVersion !== undefined && ![1, 2].includes(r.expressionVersion))
throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && (r.expressionVersion ?? 1) !== (r.checkpoint?.sim?.expressionVersion ?? 1))
throw new Error('Pet expression version does not match its checkpoint.');
this.restoreHistory(r.tape, r.interactions, r.checkpoint, r.expressionVersion ?? 1);
}
restoreHistory(tape, interactions, checkpoint, expressionVersion = 2) {
const points = this.world.sim.p.map(p => [p.hx, p.hy]);
this.world = checkpoint === undefined ? new pet_world_js_1.PetWorld(points, tape, interactions, expressionVersion)
: pet_world_js_1.PetWorld.restore(points, tape, interactions, checkpoint);
this.world = pet_world_js_1.PetWorld.fromRecording(points, JSON.parse(text));
this.segment = undefined;
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
// A restored creature does not prove an Engine operation is still active.
this.engine = new pet_engine_js_1.PetEngineTelemetry();
}
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine() {
const end = this.world.tape.length * 12;
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24)
throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) {
@@ -116,12 +108,14 @@ exports.PetNative = PetNative;
factories["pet-world"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetWorld = void 0;
exports.PetWorld = exports.PET_MAX_SECONDS = void 0;
const model_js_1 = require("./model.js");
const pet_sim_js_1 = require("./pet-sim.js");
const pet_telemetry_js_1 = require("./pet-telemetry.js");
const pet_audio_js_1 = require("./pet-audio.js");
const HZ = 30;
var pet_sim_js_2 = require("./pet-sim.js");
Object.defineProperty(exports, "PET_MAX_SECONDS", { enumerable: true, get: function () { return pet_sim_js_2.PET_MAX_SECONDS; } });
const seedFor = (name) => (0xC0FFEE ^ (0, model_js_1.stableHash)(name)) >>> 0;
/** Fixed-tick creature controller. Wall clocks and pointer APIs belong to drivers.
* Reconstructing with the same tape and interactions is also the seek operation.
@@ -156,16 +150,27 @@ class PetWorld {
food = null;
members = new Map();
lastStill = '';
constructor(points, tape = [], interactions = [], expressionVersion = 2) {
origin;
segmented = false;
hasTelemetry = false;
get startTimeMs() { return this.origin?.frame.timeMs ?? 0; }
get endTimeMs() { return Math.max(this.frame.timeMs, (this.tapeLog.at(-1)?.simTimeMs ?? 0) + 400); }
get needsSegment() {
return !this.segmented && this.tapeLog.length < 1024 && this.interactionLog.length < 4096
|| this.bucketIndex >= 1024 || this.interactionIndex >= 4096;
}
constructor(points, tape = [], interactions = [], expressionVersion = 2, segmented = false) {
if (tape.length > 216_000 || interactions.length > 100_000)
throw new Error('Pet recording exceeds its input limit.');
this.sim = new pet_sim_js_1.PetSim(points, 0xC0FFEE, expressionVersion);
this.segmented = segmented;
this.hasTelemetry = tape.length > 0;
this.tapeLog = structuredClone([...tape]);
this.interactionLog = structuredClone([...interactions]);
for (let i = 0; i < this.tape.length; i++) {
const b = this.tape[i];
(0, pet_telemetry_js_1.validatePetBucket)(b);
if (b.version !== 1 || b.sequence !== i || b.simTimeMs !== i * 400 || b.durationMs !== 400)
if (segmented ? i > 0 && b.sequence <= this.tape[i - 1].sequence : b.sequence !== i)
throw new Error('World requires contiguous version 1 pet buckets.');
}
for (let i = 0; i < this.interactionLog.length; i++) {
@@ -178,9 +183,12 @@ class PetWorld {
this.hashTape(0);
this.frame = this.makeFrame(0);
this.voices = this.score.voices(this.frame);
if (segmented)
this.origin = this.checkpoint();
}
checkpoint() {
return structuredClone({ petCheckpointVersion: 1, history: this.historyDigest(),
return structuredClone({ petCheckpointVersion: this.segmented ? 2 : 1,
...(this.segmented ? { historyStart: (this.origin?.tick ?? this.tick), hasTelemetry: this.hasTelemetry } : {}), history: this.historyDigest(),
sim: this.sim.checkpoint(), score: this.score.checkpoint(), accumulator: this.accumulator,
tick: this.tick, bucketIndex: this.bucketIndex, interactionIndex: this.interactionIndex, branchTick: this.branchTick,
random: this.random.state(), behaviour: this.behaviour, until: this.until,
@@ -189,24 +197,92 @@ class PetWorld {
waitSince: this.waitSince, food: this.food, members: [...this.members.values()], lastStill: this.lastStill,
frame: this.frame, voices: this.voices });
}
recording(withCheckpoint = true, completed = false) {
const tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const history = this.historyDigest(tapeEnd, inputEnd);
return { petReplayVersion: this.segmented ? 2 : 1, expressionVersion: this.sim.expressionVersion,
tape: this.tapeLog.slice(0, tapeEnd), interactions: this.interactionLog.slice(0, inputEnd).map(e => ({ ...e })),
...(this.origin ? { start: { ...structuredClone(this.origin), hasTelemetry: this.hasTelemetry, history } } : {}),
...(withCheckpoint ? { checkpoint: { ...this.checkpoint(), history } } : {}) };
}
static fromRecording(points, value) {
const r = value;
if (!r || ![1, 2].includes(r.petReplayVersion) || !Array.isArray(r.tape) || !Array.isArray(r.interactions))
throw new Error('Invalid pet recording.');
const version = r.expressionVersion === undefined ? 1 : r.expressionVersion;
if (![1, 2].includes(version))
throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && !r.checkpoint || r.start !== undefined && !r.start)
throw new Error('Invalid pet checkpoint.');
for (const c of [r.start, r.checkpoint])
if (c && (c.sim?.expressionVersion ?? 1) !== version)
throw new Error('Pet expression version does not match its checkpoint.');
if (r.petReplayVersion === 1 && (r.start || r.checkpoint && r.checkpoint.petCheckpointVersion !== 1))
throw new Error('Invalid legacy pet recording.');
if (r.petReplayVersion === 2 && (!r.start || r.start.petCheckpointVersion !== 2 || r.start.historyStart !== r.start.tick))
throw new Error('The recording segment is missing its starting checkpoint.');
const first = r.start && PetWorld.restore(points, r.tape, r.interactions, r.start);
const world = r.checkpoint ? PetWorld.restore(points, r.tape, r.interactions, r.checkpoint) : first ?? new PetWorld(points, r.tape, r.interactions, version);
if (first) {
if (!world.segmented || world.tick < first.tick || r.checkpoint && r.checkpoint.historyStart !== first.tick)
throw new Error('Pet segment checkpoints do not agree.');
world.origin = first.checkpoint();
}
return world;
}
/** Retire only consumed input. The exact origin makes each archived segment
* independently replayable; no particle, random stream or score is reset. */
prepareSegment() {
const dropTape = Math.max(0, this.bucketIndex), dropInputs = this.interactionIndex, previous = this.origin;
const tape = this.tapeLog.slice(dropTape), interactions = this.interactionLog.slice(dropInputs);
const points = this.sim.p.map(p => [p.hx, p.hy]);
const c = this.checkpoint();
c.petCheckpointVersion = 2;
c.historyStart = this.tick;
c.hasTelemetry = this.hasTelemetry;
c.bucketIndex -= dropTape;
c.interactionIndex = 0;
c.history = new PetWorld(points, tape, interactions, this.sim.expressionVersion, true).historyDigest();
const next = PetWorld.restore(points, tape, interactions, c);
next.origin = next.checkpoint();
let committed = false;
return { recording: next.recording(), archive: this.recording(true, true), commit: () => {
if (committed || this.origin !== previous || this.tick < c.tick)
throw new Error('The pet recording segment has changed.');
this.tapeLog.splice(0, dropTape);
this.interactionLog.splice(0, dropInputs);
this.bucketIndex -= dropTape;
this.interactionIndex -= dropInputs;
this.tapeHashes = [];
this.hashTape(0);
this.segmented = true;
this.origin = next.origin;
committed = true;
} };
}
/** Lossless version 1 export, including the current pose and score. Drivers
* consume all chunks synchronously on the world's owner before another tick.
* Only a small slice is serialized inside an embedded runtime at a time. */
recordingChunk(index) {
recordingChunk(index, completed = false) {
if (!Number.isSafeInteger(index) || index < 0)
throw new Error('Invalid pet export cursor.');
const size = 16, tapes = Math.ceil(this.tapeLog.length / size), inputs = Math.ceil(this.interactionLog.length / size);
const size = 16, tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const tapes = Math.ceil(tapeEnd / size), inputs = Math.ceil(inputEnd / size);
if (index === 0)
return `{"petReplayVersion":1,"expressionVersion":${this.sim.expressionVersion},"tape":[`;
return `{"petReplayVersion":${this.segmented ? 2 : 1},"expressionVersion":${this.sim.expressionVersion},"tape":[`;
if (index <= tapes)
return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, index * size)).slice(1, -1);
return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, Math.min(tapeEnd, index * size))).slice(1, -1);
if (index === tapes + 1)
return '],"interactions":[';
const part = index - tapes - 2;
if (part < inputs)
return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, (part + 1) * size)).slice(1, -1);
if (part === inputs)
return `],"checkpoint":${JSON.stringify(this.checkpoint())}}`;
return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, Math.min(inputEnd, (part + 1) * size))).slice(1, -1);
if (part === inputs) {
const history = this.historyDigest(tapeEnd, inputEnd);
return `]${this.origin ? ',"start":' + JSON.stringify({ ...this.origin, hasTelemetry: this.hasTelemetry, history }) : ''},"checkpoint":${JSON.stringify({ ...this.checkpoint(), history })}}`;
}
return null;
}
/** Prefix states preserve the original FNV checksum byte for byte. Live
@@ -216,9 +292,9 @@ class PetWorld {
for (let i = from; i < this.tapeLog.length; i++)
this.tapeHashes[i] = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? (0, model_js_1.stableHash)('[['));
}
historyDigest() {
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < this.interactionLog.length; i++)
historyDigest(tapeEnd = this.tapeLog.length, inputEnd = this.interactionLog.length) {
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes[tapeEnd - 1] ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < inputEnd; i++)
hash = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return (0, model_js_1.stableHash)(']]', hash);
}
@@ -228,12 +304,13 @@ class PetWorld {
const c = value;
const range = (n, low, high) => Number.isFinite(n) && n >= low && n <= high;
const integer = (n, low, high) => Number.isSafeInteger(n) && range(n, low, high);
if (!c || c.petCheckpointVersion !== 1 || JSON.stringify(c).length > 512 * 1024
|| !integer(c.tick, 0, 2_592_000) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
if (!c || ![1, 2].includes(c.petCheckpointVersion) || JSON.stringify(c).length > 512 * 1024
|| c.petCheckpointVersion === 2 && (!integer(c.historyStart, 0, c.tick) || typeof c.hasTelemetry !== 'boolean')
|| !integer(c.tick, 0, pet_sim_js_1.PET_MAX_SECONDS * HZ) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
|| !integer(c.bucketIndex, -1, tape.length - 1) || !integer(c.interactionIndex, 0, interactions.length)
|| !integer(c.branchTick, -1, c.tick) || !integer(c.random, 0, 0xffffffff)
|| !['swim', 'dive', 'roll', 'breathe', 'drift', 'doze', 'wake'].includes(c.behaviour)
|| !range(c.until, 0, 86_410) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.until, 0, pet_sim_js_1.PET_MAX_SECONDS + 10) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.lit, 0, 1) || !range(c.lastActivity, 0, c.tick / HZ)
|| c.addressedAt !== null && !range(c.addressedAt, 0, c.tick / HZ)
|| !range(c.waitSince, -1, c.tick / HZ) || typeof c.lastStill !== 'string' || c.lastStill.length > 2048
@@ -252,7 +329,11 @@ class PetWorld {
throw new Error('Invalid pet world checkpoint.');
(0, pet_sim_js_1.validatePetState)(c.frame.state);
(0, pet_audio_js_1.renderPetPCM)(c.voices, 0, 0);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1, c.petCheckpointVersion === 2);
if (c.petCheckpointVersion === 2) {
world.hasTelemetry = c.hasTelemetry;
world.origin = structuredClone(c);
}
if (c.history !== world.historyDigest()
|| c.bucketIndex >= 0 && world.tape[c.bucketIndex].simTimeMs > c.frame.timeMs + 1e-7
// acceptTelemetry may fill past gaps after the most recent fixed tick.
@@ -309,8 +390,18 @@ class PetWorld {
acceptTelemetry(input) {
(0, pet_telemetry_js_1.validatePetBucket)(input);
const sequence = Math.floor(this.tick / 12) + 1;
if (this.tapeLog.length >= 216_000)
throw new Error('Archive this pet recording before accepting more telemetry.');
this.hasTelemetry = true;
if (this.segmented) {
const at = this.tapeLog.findIndex(b => b.sequence >= sequence);
const index = at < 0 ? this.tapeLog.length : at;
this.tapeLog.splice(index, at >= 0 && this.tapeLog[at].sequence === sequence ? 1 : 0, { ...structuredClone(input), sequence, simTimeMs: sequence * 400 });
this.hashTape(index);
return;
}
if (sequence >= 216_000)
throw new Error('Start a new pet recording after 24 hours.');
throw new Error('Archive the legacy recording before accepting more telemetry.');
const changedFrom = Math.min(sequence, this.tapeLog.length);
while (this.tapeLog.length <= sequence) {
const at = this.tapeLog.length;
@@ -403,7 +494,7 @@ class PetWorld {
this.y += (this.targetY - this.y) * move;
this.flip += ((Math.abs(dx) < .015 ? this.flip < 0 ? -1 : 1 : dx < 0 ? -1 : 1) - this.flip) * .035;
this.lit += ((sleeping ? .18 : 1) - this.lit) * .03;
const wild = !this.tape.length;
const wild = !this.hasTelemetry;
const state = {
activity: telemetry?.activity ?? (wild ? sleeping ? .05 : .18 : .12),
coherence: telemetry?.coherence ?? (wild ? .94 : .25),
@@ -530,12 +621,13 @@ factories["pet-sim"]=function(exports,require){
// Positions stay normalized in body space (roughly [-0.5, 0.5]²). A renderer
// maps them through layout() to its own medium.
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetSim = exports.CHANNEL_INDEX = exports.CHANNELS = exports.ARCH_OF = exports.REST_STATE = void 0;
exports.PetSim = exports.CHANNEL_INDEX = exports.CHANNELS = exports.ARCH_OF = exports.REST_STATE = exports.PET_MAX_SECONDS = void 0;
exports.validatePetState = validatePetState;
exports.mulberry32 = mulberry32;
exports.layout = layout;
exports.digest = digest;
exports.runTape = runTape;
exports.PET_MAX_SECONDS = 100 * 365 * 86_400;
exports.REST_STATE = {
activity: 0.35, coherence: 0.8, attention: 0, channel: 'reasoning',
observed: 1, roamX: 0, roamY: 0, flip: 1, lit: 1,
@@ -797,8 +889,8 @@ class PetSim {
if (!c || c.version !== 1 || c.expressionVersion !== undefined && ![1, 2].includes(c.expressionVersion) || (c.expressionVersion ?? 1) !== this.expressionVersion || !Array.isArray(c.body) || c.body.length !== this.p.length
|| c.body.some((v, i) => !Array.isArray(v) || v.length !== 3 || v[0] !== this.p[i].hx || v[1] !== this.p[i].hy || v[2] !== this.p[i].s)
|| !Array.isArray(c.particles) || c.particles.length !== this.p.length
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 1_000_000 : 8)))
|| !inRange(c.phase, 0, 100_000) || !inRange(c.clock, 0, 86_400) || !inRange(c.tear, 0, 1)
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 2 * exports.PET_MAX_SECONDS : 8)))
|| !inRange(c.phase, 0, exports.PET_MAX_SECONDS) || !inRange(c.clock, 0, exports.PET_MAX_SECONDS) || !inRange(c.tear, 0, 1)
|| ![c.previous, c.current].every(n => Number.isInteger(n) && n >= 0 && n < exports.CHANNELS.length)
|| !Array.isArray(c.color) || c.color.length !== 3 || c.color.some(n => !inRange(n, 0, 255))
|| !c.frame || ![c.frame.r, c.frame.g, c.frame.b].every(n => inRange(n, 0, 255))
@@ -963,7 +1055,7 @@ exports.PET_BIN_MS = 400;
function validatePetBucket(value) {
(0, pet_sim_js_1.validatePetState)(value);
const b = value;
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0 || b.sequence > pet_sim_js_1.PET_MAX_SECONDS * 2.5
|| b.simTimeMs !== b.sequence * exports.PET_BIN_MS || b.durationMs !== exports.PET_BIN_MS
|| !model_js_1.CATEGORIES.includes(b.channel) || typeof b.waiting !== 'boolean'
|| !Array.isArray(b.agentIds) || b.agentIds.length > 250_000 || b.agentIds.some(id => typeof id !== 'string' || !id || id.length > 4096)
@@ -1026,7 +1118,7 @@ function compilePetTelemetry(input, durationMs = 0, firstSequence = 0, originMs
if (failures.length)
lastOnset = Math.max(lastOnset, failures[failures.length - 1]);
const count = Math.max(1, Math.ceil(durationMs / exports.PET_BIN_MS), Math.floor(lastOnset / exports.PET_BIN_MS) + 1);
if (count > 216_000)
if (count - firstSequence > 216_000 || count > pet_sim_js_1.PET_MAX_SECONDS * 2.5)
throw new Error('Pet replay exceeds 24 hours; select a shorter trace.');
const index = new signal_js_1.IntervalIndex(events), result = [];
const recent = [], names = new Map();
@@ -1387,7 +1479,7 @@ class PetScore {
checkpoint() { return [this.lastWindow, this.lastSequence, this.lastAddress]; }
restore(value) {
if (!Array.isArray(value) || value.length !== 3
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= 216_000) || typeof value[2] !== 'boolean')
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= pet_sim_js_1.PET_MAX_SECONDS * 2.5) || typeof value[2] !== 'boolean')
throw new Error('Invalid pet score checkpoint.');
[this.lastWindow, this.lastSequence, this.lastAddress] = value;
}
@@ -1478,6 +1570,7 @@ factories["pet-engine"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PetEngineTelemetry = void 0;
const pet_sim_js_1 = require("./pet-sim.js");
const codewhale_js_1 = require("./codewhale.js");
const pet_telemetry_js_1 = require("./pet-telemetry.js");
/** Read-only adapter for codewhale_protocol::EventMsg metadata. The foreground
@@ -1511,7 +1604,7 @@ class PetEngineTelemetry {
e.endTime = at;
}
observe(value, at) {
if (!Number.isFinite(at) || at < this.lastTime || at > 86_400_000)
if (!Number.isFinite(at) || at < this.lastTime || at > pet_sim_js_1.PET_MAX_SECONDS * 1000)
throw new Error('Invalid Engine pet clock.');
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Invalid Engine pet metadata.');
+11
View File
@@ -27,6 +27,17 @@ struct CodewhalePetApp: App {
if host.source == .live { Text("Local source: ~/.codewhale/pet-state").font(.caption2).textSelection(.enabled) }
if !notice.isEmpty { Text(notice).font(.caption).foregroundStyle(.secondary) }
HStack {
Menu("Earlier recordings") {
ForEach(host.archives, id: \.self) { name in
Button(petArchiveLabel(name)) {
do {
let data = try host.archivedRecording(name)
let panel = NSSavePanel(); panel.nameFieldStringValue = "codewhale-pet-earlier.json"
if panel.runModal() == .OK, let url = panel.url { try data.write(to: url, options: .atomic); notice = "Earlier recording saved." }
} catch { notice = error.localizedDescription }
}
}
}.disabled(host.archives.isEmpty)
Button("Save recording…") {
let panel = NSSavePanel(); panel.nameFieldStringValue = "codewhale-pet-replay.json"
if panel.runModal() == .OK, let url = panel.url {
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="readout"><strong id="channel">other · drift</strong><span id="behaviour">swim</span></div><span class="depth">CODE, MADE VISIBLE.</span></section>
<nav aria-label="Habitat controls"><label>World <select id="mode"><option value="wild">Wild</option><option value="demo">Event demo</option><option value="replay" disabled>Imported replay</option><option value="live" disabled>Live local tape</option></select></label>
<button id="pause" aria-pressed="false">Pause</button><button id="attention">Focus</button><button id="feed">Pulse</button><button id="sound" aria-pressed="false">Sound off</button>
<label class="toggle"><input id="motion" type="checkbox"> Still</label><label class="file">Import trace / tape<input id="file" type="file" accept=".json,.jsonl,.ndjson"></label><button id="follow">Follow local tape</button><button id="save">Save replay</button></nav>
<label class="toggle"><input id="motion" type="checkbox"> Still</label><label class="file">Import trace / tape<input id="file" type="file" accept=".json,.jsonl,.ndjson"></label><button id="follow">Follow local tape</button><button id="save">Save replay</button><select id="archive" aria-label="Earlier recordings"><option value="">Earlier recordings…</option></select></nav>
<div class="timeline"><output id="clock">0:00</output><input id="seek" type="range" min="0" max="90" step="0.1" value="0" aria-label="Replay time"><output id="end">1:30</output></div>
<p id="message" role="status">Wild mode is a simulated creature. Import event-v1, OTLP, or Codewhale telemetry to see work.</p>
<p id="persistence" role="status">The habitat saves on this device.</p>
+2 -2
View File
@@ -1,4 +1,4 @@
import { CHANNELS } from './pet-sim.js';
import { CHANNELS, PET_MAX_SECONDS } from './pet-sim.js';
import { clamp, stableHash } from './model.js';
import type { WorldFrame } from './pet-world.js';
@@ -17,7 +17,7 @@ export class PetScore {
checkpoint(): [number, number, boolean] { return [this.lastWindow, this.lastSequence, this.lastAddress]; }
restore(value: unknown): void {
if (!Array.isArray(value) || value.length !== 3
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= 216_000) || typeof value[2] !== 'boolean')
|| !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= PET_MAX_SECONDS * 2.5) || typeof value[2] !== 'boolean')
throw new Error('Invalid pet score checkpoint.');
[this.lastWindow, this.lastSequence, this.lastAddress] = value as [number, number, boolean];
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { PET_MAX_SECONDS } from './pet-sim.js';
import type { Category, WhaleEvent } from './model.js';
import { toolCategory } from './codewhale.js';
import { compilePetTelemetry, PET_BIN_MS, type PetBucket } from './pet-telemetry.js';
@@ -31,7 +32,7 @@ export class PetEngineTelemetry {
}
observe(value: unknown, at: number): void {
if (!Number.isFinite(at) || at < this.lastTime || at > 86_400_000) throw new Error('Invalid Engine pet clock.');
if (!Number.isFinite(at) || at < this.lastTime || at > PET_MAX_SECONDS * 1000) throw new Error('Invalid Engine pet clock.');
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Engine pet metadata.');
const e = value as Record<string, unknown>;
const allowed = ['event', 'index', 'channel', 'tool_call_id', 'tool_name', 'id', 'worker_status', 'failed'];
+13 -19
View File
@@ -1,4 +1,4 @@
import { PetWorld, type PetInteraction } from './pet-world.js';
import { PetWorld, type PetInteraction, type PetSegment } from './pet-world.js';
import { compilePetTelemetry, decodePetJSONL } from './pet-telemetry.js';
import { ARCH_OF, digest, layout } from './pet-sim.js';
import { renderPetPCM, type PetVoice } from './pet-audio.js';
@@ -10,45 +10,39 @@ export class PetNative {
private world: PetWorld;
private engine = new PetEngineTelemetry();
private engineTick = 0;
private segment?: PetSegment;
constructor(pointsJSON: string, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion: 1 | 2 = 2) {
const points = JSON.parse(pointsJSON) as [number, number][];
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
throw new Error('Invalid native whale body.');
this.world = new PetWorld(points, live ? compilePetTelemetry([]) : decodePetJSONL(tapeJSONL), JSON.parse(interactionsJSON) as PetInteraction[], expressionVersion);
this.world = new PetWorld(points, live ? compilePetTelemetry([]) : decodePetJSONL(tapeJSONL), JSON.parse(interactionsJSON) as PetInteraction[], expressionVersion, true);
}
step(dt: number, motion: boolean): string { this.world.step(dt, { motion, sensitivity: 1 }); return this.snapshot(); }
snapshot(): string { return JSON.stringify({ ...this.world.frame, voices: this.world.voices, digest: digest(this.world.sim) }); }
interact(kind: PetInteraction['kind'], x: number, y: number): void { this.world.interact(kind, x, y); }
interactions(): string { return JSON.stringify(this.world.interactions); }
accept(packet: string): void { this.world.acceptTelemetry(JSON.parse(packet)); }
recording(withCheckpoint = false): string { return JSON.stringify({ petReplayVersion: 1, expressionVersion: this.world.sim.expressionVersion, tape: this.world.tape,
interactions: this.world.interactions, ...(withCheckpoint ? { checkpoint: this.world.checkpoint() } : {}) }); }
recording(withCheckpoint = false): string { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment(): boolean { return this.world.needsSegment; }
prepareSegment(): string { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
commitSegment(): void { if (!this.segment) throw new Error('No pet segment was prepared.'); this.segment.commit(); this.segment = undefined; }
checkpoint(): string { return JSON.stringify(this.world.checkpoint()); }
recordingChunk(index: number): string | null { return this.world.recordingChunk(index); }
recordingChunk(index: number, completed = false): string | null { return this.world.recordingChunk(index, completed); }
restoreCheckpoint(text: string): void {
if (text.length > 512 * 1024) throw new Error('Pet checkpoint exceeds its size limit.');
this.restoreHistory(this.world.tape, this.world.interactions, JSON.parse(text));
this.restoreRecording(JSON.stringify({ ...this.world.recording(false), checkpoint: JSON.parse(text) }));
}
restoreRecording(text: string): void {
if (text.length > 8 * 1024 * 1024) throw new Error('Native habitat exceeds 8 MiB.');
const r = JSON.parse(text);
if (!r || r.petReplayVersion !== 1 || !Array.isArray(r.tape) || !Array.isArray(r.interactions)) throw new Error('Invalid native habitat.');
if (r.expressionVersion !== undefined && ![1, 2].includes(r.expressionVersion)) throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && (r.expressionVersion ?? 1) !== (r.checkpoint?.sim?.expressionVersion ?? 1)) throw new Error('Pet expression version does not match its checkpoint.');
this.restoreHistory(r.tape, r.interactions, r.checkpoint, r.expressionVersion ?? 1);
}
private restoreHistory(tape: PetWorld['tape'], interactions: PetWorld['interactions'], checkpoint: unknown, expressionVersion: 1 | 2 = 2): void {
const points = this.world.sim.p.map(p => [p.hx, p.hy] as [number, number]);
this.world = checkpoint === undefined ? new PetWorld(points, tape, interactions, expressionVersion)
: PetWorld.restore(points, tape, interactions, checkpoint);
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
// A restored creature does not prove an Engine operation is still active.
this.engine = new PetEngineTelemetry();
this.world = PetWorld.fromRecording(points, JSON.parse(text));
this.segment = undefined; this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000); this.engine = new PetEngineTelemetry();
}
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine(): number {
const end = this.world.tape.length * 12;
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24) throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) { this.world.step(1 / 30, { motion: false, sensitivity: 1 }); this.engineTick++; }
this.engine = new PetEngineTelemetry();
+4 -2
View File
@@ -17,6 +17,8 @@
// Positions stay normalized in body space (roughly [-0.5, 0.5]²). A renderer
// maps them through layout() to its own medium.
export const PET_MAX_SECONDS = 100 * 365 * 86_400;
export interface PetState {
activity: number; // how much work 0..1
coherence: number; // converging school vs thrashing 0..1
@@ -312,8 +314,8 @@ export class PetSim {
if (!c || c.version !== 1 || c.expressionVersion !== undefined && ![1, 2].includes(c.expressionVersion) || (c.expressionVersion ?? 1) !== this.expressionVersion || !Array.isArray(c.body) || c.body.length !== this.p.length
|| c.body.some((v, i) => !Array.isArray(v) || v.length !== 3 || v[0] !== this.p[i].hx || v[1] !== this.p[i].hy || v[2] !== this.p[i].s)
|| !Array.isArray(c.particles) || c.particles.length !== this.p.length
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 1_000_000 : 8)))
|| !inRange(c.phase, 0, 100_000) || !inRange(c.clock, 0, 86_400) || !inRange(c.tear, 0, 1)
|| c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 2 * PET_MAX_SECONDS : 8)))
|| !inRange(c.phase, 0, PET_MAX_SECONDS) || !inRange(c.clock, 0, PET_MAX_SECONDS) || !inRange(c.tear, 0, 1)
|| ![c.previous, c.current].every(n => Number.isInteger(n) && n >= 0 && n < CHANNELS.length)
|| !Array.isArray(c.color) || c.color.length !== 3 || c.color.some(n => !inRange(n, 0, 255))
|| !c.frame || ![c.frame.r, c.frame.g, c.frame.b].every(n => inRange(n, 0, 255))
+3 -3
View File
@@ -1,6 +1,6 @@
import { CATEGORIES, clamp, errorOnsetOf, type Category, type WhaleEvent } from './model.js';
import { IntervalIndex } from './signal.js';
import { validatePetState, type PetState } from './pet-sim.js';
import { PET_MAX_SECONDS, validatePetState, type PetState } from './pet-sim.js';
/** One projection for imports, demos and recorded live snapshots. Times are ms.
* These are aesthetic encodings of measured events, never model confidence. */
@@ -20,7 +20,7 @@ export interface PetBucket extends PetState {
export function validatePetBucket(value: unknown): asserts value is PetBucket {
validatePetState(value);
const b = value as PetBucket;
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0
if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0 || b.sequence > PET_MAX_SECONDS * 2.5
|| b.simTimeMs !== b.sequence * PET_BIN_MS || b.durationMs !== PET_BIN_MS
|| !CATEGORIES.includes(b.channel as Category) || typeof b.waiting !== 'boolean'
|| !Array.isArray(b.agentIds) || b.agentIds.length > 250_000 || b.agentIds.some(id => typeof id !== 'string' || !id || id.length > 4096)
@@ -73,7 +73,7 @@ export function compilePetTelemetry(input: readonly WhaleEvent[], durationMs = 0
const failures = events.filter(e => e.category === 'error' || e.status === 'error').map(errorOnsetOf).sort((a, b) => a - b);
if (failures.length) lastOnset = Math.max(lastOnset, failures[failures.length - 1]);
const count = Math.max(1, Math.ceil(durationMs / PET_BIN_MS), Math.floor(lastOnset / PET_BIN_MS) + 1);
if (count > 216_000) throw new Error('Pet replay exceeds 24 hours; select a shorter trace.');
if (count - firstSequence > 216_000 || count > PET_MAX_SECONDS * 2.5) throw new Error('Pet replay exceeds 24 hours; select a shorter trace.');
const index = new IntervalIndex(events), result: PetBucket[] = [];
const recent: WhaleEvent[] = [], names = new Map<string, number>();
let next = 0, expired = 0, nextFailure = 0;
+112 -20
View File
@@ -1,5 +1,5 @@
import { clamp, stableHash } from './model.js';
import { PetSim, mulberry32, validatePetState, type PetOpts, type PetState, type PetSimCheckpoint } from './pet-sim.js';
import { PetSim, PET_MAX_SECONDS, mulberry32, validatePetState, type PetOpts, type PetState, type PetSimCheckpoint } from './pet-sim.js';
import { validatePetBucket, type PetBucket } from './pet-telemetry.js';
import { PetScore, renderPetPCM, type PetVoice } from './pet-audio.js';
@@ -12,7 +12,9 @@ export interface WorldFrame {
surface: number; caustic: number; food: { x: number; y: number; life: number } | null;
}
export interface PetWorldCheckpoint {
petCheckpointVersion: 1;
petCheckpointVersion: 1 | 2;
historyStart?: number;
hasTelemetry?: boolean;
/** Detects accidentally pairing a checkpoint with another recording. This
* is a consistency checksum, not authentication of imported telemetry. */
history: number;
@@ -26,7 +28,20 @@ export interface PetWorldCheckpoint {
members: PodMember[]; lastStill: string;
frame: WorldFrame; voices: PetVoice[];
}
export interface PetRecording {
petReplayVersion: 1 | 2; expressionVersion?: 1 | 2;
tape: readonly PetBucket[]; interactions: readonly PetInteraction[];
start?: PetWorldCheckpoint; checkpoint?: PetWorldCheckpoint;
}
export interface PetSegment {
recording: PetRecording;
archive: PetRecording;
/** Call only after the outgoing recording and new habitat commit together.
* Ticks/inputs accepted while a browser transaction waits remain in memory. */
commit(): void;
}
const HZ = 30;
export { PET_MAX_SECONDS } from './pet-sim.js';
const seedFor = (name: string) => (0xC0FFEE ^ stableHash(name)) >>> 0;
/** Fixed-tick creature controller. Wall clocks and pointer APIs belong to drivers.
@@ -62,16 +77,26 @@ export class PetWorld {
private food: { time: number; x: number; y: number } | null = null;
private members = new Map<string, PodMember>();
private lastStill = '';
private origin?: PetWorldCheckpoint;
private segmented = false;
private hasTelemetry = false;
get startTimeMs(): number { return this.origin?.frame.timeMs ?? 0; }
get endTimeMs(): number { return Math.max(this.frame.timeMs, (this.tapeLog.at(-1)?.simTimeMs ?? 0) + 400); }
get needsSegment(): boolean {
return !this.segmented && this.tapeLog.length < 1024 && this.interactionLog.length < 4096
|| this.bucketIndex >= 1024 || this.interactionIndex >= 4096;
}
constructor(points: [number, number][], tape: readonly PetBucket[] = [], interactions: readonly PetInteraction[] = [], expressionVersion: 1 | 2 = 2) {
constructor(points: [number, number][], tape: readonly PetBucket[] = [], interactions: readonly PetInteraction[] = [], expressionVersion: 1 | 2 = 2, segmented = false) {
if (tape.length > 216_000 || interactions.length > 100_000) throw new Error('Pet recording exceeds its input limit.');
this.sim = new PetSim(points, 0xC0FFEE, expressionVersion);
this.segmented = segmented; this.hasTelemetry = tape.length > 0;
this.tapeLog = structuredClone([...tape]);
this.interactionLog = structuredClone([...interactions]);
for (let i = 0; i < this.tape.length; i++) {
const b = this.tape[i];
validatePetBucket(b);
if (b.version !== 1 || b.sequence !== i || b.simTimeMs !== i * 400 || b.durationMs !== 400)
if (segmented ? i > 0 && b.sequence <= this.tape[i - 1].sequence : b.sequence !== i)
throw new Error('World requires contiguous version 1 pet buckets.');
}
for (let i = 0; i < this.interactionLog.length; i++) {
@@ -83,10 +108,12 @@ export class PetWorld {
this.hashTape(0);
this.frame = this.makeFrame(0);
this.voices = this.score.voices(this.frame);
if (segmented) this.origin = this.checkpoint();
}
checkpoint(): PetWorldCheckpoint {
return structuredClone({ petCheckpointVersion: 1, history: this.historyDigest(),
return structuredClone({ petCheckpointVersion: this.segmented ? 2 : 1,
...(this.segmented ? { historyStart: (this.origin?.tick ?? this.tick), hasTelemetry: this.hasTelemetry } : {}), history: this.historyDigest(),
sim: this.sim.checkpoint(), score: this.score.checkpoint(), accumulator: this.accumulator,
tick: this.tick, bucketIndex: this.bucketIndex, interactionIndex: this.interactionIndex, branchTick: this.branchTick,
random: this.random.state(), behaviour: this.behaviour, until: this.until,
@@ -96,18 +123,72 @@ export class PetWorld {
frame: this.frame, voices: this.voices });
}
recording(withCheckpoint = true, completed = false): PetRecording {
const tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const history = this.historyDigest(tapeEnd, inputEnd);
return { petReplayVersion: this.segmented ? 2 : 1, expressionVersion: this.sim.expressionVersion,
tape: this.tapeLog.slice(0, tapeEnd), interactions: this.interactionLog.slice(0, inputEnd).map(e => ({ ...e })),
...(this.origin ? { start: { ...structuredClone(this.origin), hasTelemetry: this.hasTelemetry, history } } : {}),
...(withCheckpoint ? { checkpoint: { ...this.checkpoint(), history } } : {}) };
}
static fromRecording(points: [number, number][], value: unknown): PetWorld {
const r = value as PetRecording;
if (!r || ![1, 2].includes(r.petReplayVersion) || !Array.isArray(r.tape) || !Array.isArray(r.interactions)) throw new Error('Invalid pet recording.');
const version = r.expressionVersion === undefined ? 1 : r.expressionVersion;
if (![1, 2].includes(version)) throw new Error('Unsupported pet expression version.');
if (r.checkpoint !== undefined && !r.checkpoint || r.start !== undefined && !r.start) throw new Error('Invalid pet checkpoint.');
for (const c of [r.start, r.checkpoint]) if (c && (c.sim?.expressionVersion ?? 1) !== version) throw new Error('Pet expression version does not match its checkpoint.');
if (r.petReplayVersion === 1 && (r.start || r.checkpoint && r.checkpoint.petCheckpointVersion !== 1)) throw new Error('Invalid legacy pet recording.');
if (r.petReplayVersion === 2 && (!r.start || r.start.petCheckpointVersion !== 2 || r.start.historyStart !== r.start.tick)) throw new Error('The recording segment is missing its starting checkpoint.');
const first = r.start && PetWorld.restore(points, r.tape, r.interactions, r.start);
const world = r.checkpoint ? PetWorld.restore(points, r.tape, r.interactions, r.checkpoint) : first ?? new PetWorld(points, r.tape, r.interactions, version);
if (first) {
if (!world.segmented || world.tick < first.tick || r.checkpoint && r.checkpoint.historyStart !== first.tick) throw new Error('Pet segment checkpoints do not agree.');
world.origin = first.checkpoint();
}
return world;
}
/** Retire only consumed input. The exact origin makes each archived segment
* independently replayable; no particle, random stream or score is reset. */
prepareSegment(): PetSegment {
const dropTape = Math.max(0, this.bucketIndex), dropInputs = this.interactionIndex, previous = this.origin;
const tape = this.tapeLog.slice(dropTape), interactions = this.interactionLog.slice(dropInputs);
const points = this.sim.p.map(p => [p.hx, p.hy] as [number, number]);
const c = this.checkpoint();
c.petCheckpointVersion = 2; c.historyStart = this.tick; c.hasTelemetry = this.hasTelemetry;
c.bucketIndex -= dropTape; c.interactionIndex = 0;
c.history = new PetWorld(points, tape, interactions, this.sim.expressionVersion, true).historyDigest();
const next = PetWorld.restore(points, tape, interactions, c); next.origin = next.checkpoint();
let committed = false;
return { recording: next.recording(), archive: this.recording(true, true), commit: () => {
if (committed || this.origin !== previous || this.tick < c.tick) throw new Error('The pet recording segment has changed.');
this.tapeLog.splice(0, dropTape); this.interactionLog.splice(0, dropInputs);
this.bucketIndex -= dropTape; this.interactionIndex -= dropInputs;
this.tapeHashes = []; this.hashTape(0); this.segmented = true; this.origin = next.origin;
committed = true;
} };
}
/** Lossless version 1 export, including the current pose and score. Drivers
* consume all chunks synchronously on the world's owner before another tick.
* Only a small slice is serialized inside an embedded runtime at a time. */
recordingChunk(index: number): string | null {
recordingChunk(index: number, completed = false): string | null {
if (!Number.isSafeInteger(index) || index < 0) throw new Error('Invalid pet export cursor.');
const size = 16, tapes = Math.ceil(this.tapeLog.length / size), inputs = Math.ceil(this.interactionLog.length / size);
if (index === 0) return `{"petReplayVersion":1,"expressionVersion":${this.sim.expressionVersion},"tape":[`;
if (index <= tapes) return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, index * size)).slice(1, -1);
const size = 16, tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
const tapes = Math.ceil(tapeEnd / size), inputs = Math.ceil(inputEnd / size);
if (index === 0) return `{"petReplayVersion":${this.segmented ? 2 : 1},"expressionVersion":${this.sim.expressionVersion},"tape":[`;
if (index <= tapes) return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, Math.min(tapeEnd, index * size))).slice(1, -1);
if (index === tapes + 1) return '],"interactions":[';
const part = index - tapes - 2;
if (part < inputs) return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, (part + 1) * size)).slice(1, -1);
if (part === inputs) return `],"checkpoint":${JSON.stringify(this.checkpoint())}}`;
if (part < inputs) return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, Math.min(inputEnd, (part + 1) * size))).slice(1, -1);
if (part === inputs) {
const history = this.historyDigest(tapeEnd, inputEnd);
return `]${this.origin ? ',"start":' + JSON.stringify({ ...this.origin, hasTelemetry: this.hasTelemetry, history }) : ''},"checkpoint":${JSON.stringify({ ...this.checkpoint(), history })}}`;
}
return null;
}
@@ -118,9 +199,9 @@ export class PetWorld {
for (let i = from; i < this.tapeLog.length; i++)
this.tapeHashes[i] = stableHash((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? stableHash('[['));
}
private historyDigest(): number {
let hash = stableHash('],[', this.tapeHashes.at(-1) ?? stableHash('[['));
for (let i = 0; i < this.interactionLog.length; i++)
private historyDigest(tapeEnd = this.tapeLog.length, inputEnd = this.interactionLog.length): number {
let hash = stableHash('],[', this.tapeHashes[tapeEnd - 1] ?? stableHash('[['));
for (let i = 0; i < inputEnd; i++)
hash = stableHash((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return stableHash(']]', hash);
}
@@ -131,12 +212,13 @@ export class PetWorld {
const c = value as PetWorldCheckpoint;
const range = (n: number, low: number, high: number) => Number.isFinite(n) && n >= low && n <= high;
const integer = (n: number, low: number, high: number) => Number.isSafeInteger(n) && range(n, low, high);
if (!c || c.petCheckpointVersion !== 1 || JSON.stringify(c).length > 512 * 1024
|| !integer(c.tick, 0, 2_592_000) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
if (!c || ![1, 2].includes(c.petCheckpointVersion) || JSON.stringify(c).length > 512 * 1024
|| c.petCheckpointVersion === 2 && (!integer(c.historyStart!, 0, c.tick) || typeof c.hasTelemetry !== 'boolean')
|| !integer(c.tick, 0, PET_MAX_SECONDS * HZ) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
|| !integer(c.bucketIndex, -1, tape.length - 1) || !integer(c.interactionIndex, 0, interactions.length)
|| !integer(c.branchTick, -1, c.tick) || !integer(c.random, 0, 0xffffffff)
|| !['swim', 'dive', 'roll', 'breathe', 'drift', 'doze', 'wake'].includes(c.behaviour)
|| !range(c.until, 0, 86_410) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.until, 0, PET_MAX_SECONDS + 10) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
|| !range(c.lit, 0, 1) || !range(c.lastActivity, 0, c.tick / HZ)
|| c.addressedAt !== null && !range(c.addressedAt, 0, c.tick / HZ)
|| !range(c.waitSince, -1, c.tick / HZ) || typeof c.lastStill !== 'string' || c.lastStill.length > 2048
@@ -155,7 +237,8 @@ export class PetWorld {
throw new Error('Invalid pet world checkpoint.');
validatePetState(c.frame.state);
renderPetPCM(c.voices, 0, 0);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1);
const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1, c.petCheckpointVersion === 2);
if (c.petCheckpointVersion === 2) { world.hasTelemetry = c.hasTelemetry!; world.origin = structuredClone(c); }
if (c.history !== world.historyDigest()
|| c.bucketIndex >= 0 && world.tape[c.bucketIndex].simTimeMs > c.frame.timeMs + 1e-7
// acceptTelemetry may fill past gaps after the most recent fixed tick.
@@ -198,7 +281,16 @@ export class PetWorld {
acceptTelemetry(input: PetBucket): void {
validatePetBucket(input);
const sequence = Math.floor(this.tick / 12) + 1;
if (sequence >= 216_000) throw new Error('Start a new pet recording after 24 hours.');
if (this.tapeLog.length >= 216_000) throw new Error('Archive this pet recording before accepting more telemetry.');
this.hasTelemetry = true;
if (this.segmented) {
const at = this.tapeLog.findIndex(b => b.sequence >= sequence);
const index = at < 0 ? this.tapeLog.length : at;
this.tapeLog.splice(index, at >= 0 && this.tapeLog[at].sequence === sequence ? 1 : 0,
{ ...structuredClone(input), sequence, simTimeMs: sequence * 400 });
this.hashTape(index); return;
}
if (sequence >= 216_000) throw new Error('Archive the legacy recording before accepting more telemetry.');
const changedFrom = Math.min(sequence, this.tapeLog.length);
while (this.tapeLog.length <= sequence) {
const at = this.tapeLog.length;
@@ -270,7 +362,7 @@ export class PetWorld {
this.x += dx * move; this.y += (this.targetY - this.y) * move;
this.flip += ((Math.abs(dx) < .015 ? this.flip < 0 ? -1 : 1 : dx < 0 ? -1 : 1) - this.flip) * .035;
this.lit += ((sleeping ? .18 : 1) - this.lit) * .03;
const wild = !this.tape.length;
const wild = !this.hasTelemetry;
const state: PetState = {
activity: telemetry?.activity ?? (wild ? sleeping ? .05 : .18 : .12),
coherence: telemetry?.coherence ?? (wild ? .94 : .25),
+85 -49
View File
@@ -1,4 +1,4 @@
import { PetWorld, type PetInteraction } from '../core/pet-world.js';
import { PetWorld, PET_MAX_SECONDS, type PetInteraction, type PetWorldCheckpoint } from '../core/pet-world.js';
import { compilePetTelemetry, decodePetJSONL, validatePetBucket, type PetBucket } from '../core/pet-telemetry.js';
import { petDemoEvents } from '../core/pet-demo.js';
import { importTrace } from '../core/ingest.js';
@@ -11,9 +11,10 @@ const canvas = get<HTMLCanvasElement>('tank'), ctx = canvas.getContext('2d')!;
const mode = get<HTMLSelectElement>('mode'), seek = get<HTMLInputElement>('seek'), motion = get<HTMLInputElement>('motion');
const message = get('message'), source = get('source');
const media = matchMedia('(prefers-reduced-motion: reduce)'); motion.checked = media.matches;
media.addEventListener('change', () => { motion.checked = media.matches; rebuild(seconds); });
media.addEventListener('change', () => { motion.checked = media.matches; if (world) rebuild(seconds, undefined, world.recording(false).start); });
let points: [number, number][] = [], tape: readonly PetBucket[] = [], interactions: PetInteraction[] = [];
let expressionVersion: 1 | 2 = 2;
let worldMode = 'wild';
let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false;
let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0;
let imported: { world: PetWorld; name: string } | undefined;
@@ -39,15 +40,16 @@ function play(voices: readonly PetVoice[]) {
node.start(Math.max(audio.currentTime, at), Math.max(0, audio.currentTime - at));
}
}
async function rebuild(to = 0, checkpoint?: import('../core/pet-world.js').PetWorldCheckpoint) {
async function rebuild(to = 0, checkpoint?: PetWorldCheckpoint, start?: PetWorldCheckpoint) {
const ticket = ++generation;
const next = checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion);
const ticks = Math.round(Math.max(0, Math.min(86_400, to)) * 30);
const next = start ? PetWorld.fromRecording(points, { petReplayVersion: 2, expressionVersion, tape, interactions, start, checkpoint })
: checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion, true);
const ticks = Math.round(Math.max(next.startTimeMs / 1000, Math.min(PET_MAX_SECONDS, to)) * 30);
if (checkpoint && checkpoint.tick !== ticks) throw new Error('Saved pet clock does not match its checkpoint.');
silence(); restoring = true;
canvas.setAttribute('aria-busy', 'true');
for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = true;
for (let i = checkpoint?.tick ?? 0; i < ticks; i++) {
for (let i = Math.round(next.frame.timeMs * 30 / 1000); i < ticks; i++) {
next.step(1 / 30, { motion: !motion.checked, sensitivity: 1 });
if (i > 0 && i % 600 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); if (ticket !== generation) return; }
}
@@ -55,7 +57,8 @@ async function rebuild(to = 0, checkpoint?: import('../core/pet-world.js').PetWo
adoptWorld(next);
}
function adoptWorld(next: PetWorld) {
world = next; expressionVersion = next.sim.expressionVersion; restoring = false;
world = next; worldMode = mode.value; tape = world.tape; interactions = [...world.interactions];
seek.min = String(next.startTimeMs / 1000); expressionVersion = next.sim.expressionVersion; restoring = false;
if (mode.value === 'replay') imported = { world: next, name: imported?.name ?? source.textContent ?? 'Imported replay' };
canvas.setAttribute('aria-busy', 'false');
for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = false;
@@ -64,18 +67,56 @@ function adoptWorld(next: PetWorld) {
draw();
}
function stopFollowing() { liveGeneration++; clearTimeout(liveTimer); get<HTMLButtonElement>('pause').disabled = false; seek.disabled = false; }
async function persist() {
if (!world || restoring || saving || !persistenceReady || persistenceFailed) return;
async function refreshArchives() {
const list = get<HTMLSelectElement>('archive');
const entries = await library.petArchives();
list.replaceChildren(new Option('Earlier recordings…', ''));
for (const entry of entries) list.add(new Option(`${entry.name} · ${timeLabel(entry.startSeconds)}–${timeLabel(entry.seconds)}`, String(entry.key)));
}
async function persist(archiveCurrent = false): Promise<boolean> {
if (!world || restoring || saving || !persistenceReady || persistenceFailed) return false;
saving = true;
try {
const snapshot: SavedHabitat = { petPersistenceVersion: 1, seconds, source: mode.value === 'live' ? 'replay' : mode.value as SavedHabitat['source'],
sourceName: mode.value === 'live' ? 'Saved live recording' : source.textContent ?? '', still: motion.checked,
tape: world.tape, interactions: world.interactions, checkpoint: world.checkpoint(), expressionVersion: world.sim.expressionVersion };
savedRevision = await library.saveHabitat(snapshot, savedRevision); get('persistence').textContent = 'Habitat saved on this device.';
const owner = world;
const snapshot: SavedHabitat = { petPersistenceVersion: 1, seconds: owner.frame.timeMs / 1000,
source: worldMode === 'live' ? 'replay' : worldMode as SavedHabitat['source'],
sourceName: worldMode === 'live' ? 'Saved live recording' : source.textContent ?? '', still: motion.checked,
...owner.recording() };
const segment = archiveCurrent || owner.needsSegment ? owner.prepareSegment() : undefined;
savedRevision = await library.saveHabitat(segment ? { ...snapshot, ...segment.recording } : snapshot, savedRevision,
archiveCurrent ? snapshot : segment ? { ...snapshot, ...segment.archive } : undefined);
if (segment) {
segment.commit();
if (world === owner) { tape = owner.tape; interactions = [...owner.interactions]; seek.min = String(owner.startTimeMs / 1000); }
void refreshArchives().catch(() => {});
}
get('persistence').textContent = 'Habitat saved on this device. Earlier recordings remain available.';
return true;
}
catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; }
catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; return false; }
finally { saving = false; }
}
async function mayLeave(): Promise<boolean> {
return await persist(true) || window.confirm('This visit could not be saved. Cancel to keep it and export a replay, or leave without saving its latest progress.');
}
function adoptImported(next: PetWorld, name: string) {
stopFollowing(); mode.value = 'replay';
imported = { world: next, name }; source.textContent = name;
mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
seek.max = String(Math.max(90, next.endTimeMs / 1000));
++generation; silence(); adoptWorld(next);
}
get<HTMLSelectElement>('archive').onchange = async event => {
const picker = event.target as HTMLSelectElement, key = Number(picker.value); picker.value = '';
if (!key) return;
try {
const saved = await library.petArchive(key);
const next = PetWorld.fromRecording(points, { ...saved, petReplayVersion: saved.petReplayVersion ?? 1 });
if (!await mayLeave()) return;
adoptImported(next, saved.sourceName); message.textContent = 'Earlier recording opened. Its original remains in local storage.';
} catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to open this recording.'; }
};
function draw() {
if (!world) return;
const w = canvas.clientWidth, h = canvas.clientHeight, dpr = Math.min(2, devicePixelRatio || 1);
@@ -113,12 +154,13 @@ get('sound').onclick = async () => {
get('sound').textContent = sound ? 'Sound on' : 'Sound off'; get('sound').setAttribute('aria-pressed', String(sound));
} catch { message.textContent = 'Audio is unavailable in this browser. The visual replay remains available.'; }
};
motion.onchange = () => rebuild(seconds); seek.oninput = () => rebuild(Number(seek.value));
mode.onchange = () => {
motion.onchange = () => rebuild(seconds, undefined, world.recording(false).start); seek.oninput = () => rebuild(Number(seek.value), undefined, world.recording(false).start);
mode.onchange = async () => {
if (!await mayLeave()) { mode.value = worldMode; return; }
stopFollowing();
if (mode.value === 'replay' && imported) {
tape = imported.world.tape; interactions = [...imported.world.interactions]; source.textContent = imported.name;
seek.max = String(Math.max(90, tape.length * .4, imported.world.frame.timeMs / 1000));
seek.max = String(Math.max(90, imported.world.endTimeMs / 1000));
++generation; silence(); adoptWorld(imported.world);
message.textContent = 'Returned to the imported world at its current pose.'; return;
}
@@ -129,40 +171,29 @@ mode.onchange = () => {
message.textContent = mode.value === 'demo' ? 'Synthetic event-v1 telemetry uses the same derivation as imported traces.' : 'Wild mode is a simulated creature. Import event-v1, OTLP, or Codewhale telemetry to see work.';
};
get<HTMLInputElement>('file').onchange = async event => {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file) return;
try {
if (file.size > 64 * 1024 * 1024) throw new Error('File exceeds 64 MiB.');
if (file.size > 64 * 1024 * 1024) throw new Error('Pet import exceeds 64 MiB.');
const text = await file.text(); let replay: unknown;
try { replay = JSON.parse(text); } catch { /* JSONL is decoded below. */ }
let nextInteractions: PetInteraction[] = [], nextTape: PetBucket[], nextExpressionVersion: 1 | 2 = 2;
let nextCheckpoint: import('../core/pet-world.js').PetWorldCheckpoint | undefined;
if (replay && typeof replay === 'object' && 'petReplayVersion' in replay) {
const r = replay as { petReplayVersion: number; expressionVersion?: 1 | 2; tape: unknown; interactions: PetInteraction[]; checkpoint?: import('../core/pet-world.js').PetWorldCheckpoint };
if (r.petReplayVersion !== 1 || !Array.isArray(r.tape) || !Array.isArray(r.interactions)) throw new Error('Invalid pet replay.');
nextExpressionVersion = r.expressionVersion === undefined ? 1 : r.expressionVersion;
if (![1, 2].includes(nextExpressionVersion)) throw new Error('Unsupported pet expression version.');
nextTape = decodePetJSONL(r.tape.map(b => JSON.stringify(b)).join('\n')); nextInteractions = r.interactions;
if (r.checkpoint !== undefined) {
if ((r.checkpoint?.sim?.expressionVersion ?? 1) !== nextExpressionVersion) throw new Error('Pet expression version does not match its checkpoint.');
nextCheckpoint = r.checkpoint;
}
} else {
try { replay = JSON.parse(text); } catch { /* JSONL and trace imports follow below. */ }
let next: PetWorld;
if (replay && typeof replay === 'object' && 'petReplayVersion' in replay) next = PetWorld.fromRecording(points, replay);
else {
let nextTape: readonly PetBucket[];
const first = replay ?? JSON.parse(text.split(/\r?\n/).find(line => line.trim()) || '{}');
if (first && typeof first === 'object' && 'version' in first && first.version === 1 && 'simTimeMs' in first) nextTape = decodePetJSONL(text);
else { const traces = importTrace(text, file.name, { privacy: 'metadata' });
if (traces.length !== 1) throw new Error('Import a single trace. Select and export it in Whalesong first.');
nextTape = compilePetTelemetry(traces[0].events, traces[0].duration); }
else {
const traces = importTrace(text, file.name, { privacy: 'metadata' });
if (traces.length !== 1) throw new Error('Choose one recording to import.');
nextTape = compilePetTelemetry(traces[0].events, traces[0].duration);
}
next = new PetWorld(points, nextTape, [], 2, true);
}
// Validate before replacing the currently playing world.
const next = nextCheckpoint === undefined ? new PetWorld(points, nextTape, nextInteractions, nextExpressionVersion)
: PetWorld.restore(points, nextTape, nextInteractions, nextCheckpoint);
stopFollowing();
expressionVersion = nextExpressionVersion; tape = nextTape; interactions = nextInteractions; mode.value = 'replay'; seek.max = String(Math.max(90, tape.length * .4, (nextCheckpoint?.frame.timeMs ?? 0) / 1000));
imported = { world: next, name: `Local replay · ${file.name}` };
mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
source.textContent = `Local replay · ${file.name}`; message.textContent = 'Recording loaded locally. The saved world includes interactions, its current pose and audio onsets.';
++generation; silence(); adoptWorld(next);
if (!await mayLeave()) return;
adoptImported(next, `Local replay · ${file.name}`);
message.textContent = 'Recording loaded locally, including its current pose, interactions and score.';
} catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to import this file.'; }
finally { input.value = ''; }
};
get('save').onclick = () => {
try {
@@ -185,6 +216,7 @@ get('follow').onclick = async () => {
try {
const [handle] = await picker.call(window); if (!handle) return;
const file = await handle.getFile();
if (!await mayLeave()) return;
stopFollowing(); const ticket = liveGeneration;
mode.value = 'live'; paused = false; get('pause').textContent = 'Pause'; get('pause').setAttribute('aria-pressed', 'false');
get<HTMLButtonElement>('pause').disabled = true; seek.disabled = true;
@@ -232,21 +264,25 @@ try {
const saved = await library.getHabitat();
if (saved) {
savedRevision = saved.revision; const h = saved.habitat;
if (h.petPersistenceVersion !== 1 || !Number.isFinite(h.seconds) || h.seconds < 0 || h.seconds > 86_400
if (h.petPersistenceVersion !== 1 || !Number.isFinite(h.seconds) || h.seconds < 0 || h.seconds > PET_MAX_SECONDS
|| !['wild', 'demo', 'replay'].includes(h.source) || typeof h.still !== 'boolean' || typeof h.sourceName !== 'string') throw new Error('Saved habitat is invalid. Save a replay file before replacing it.');
expressionVersion = h.expressionVersion === undefined ? 1 : h.expressionVersion;
if (![1, 2].includes(expressionVersion) || h.checkpoint && (h.checkpoint.sim?.expressionVersion ?? 1) !== expressionVersion) throw new Error('Saved expression version does not match its checkpoint.');
const savedWorld = PetWorld.fromRecording(points, { ...h, petReplayVersion: h.petReplayVersion === undefined ? 1 : h.petReplayVersion });
if (h.checkpoint && savedWorld.frame.timeMs / 1000 !== h.seconds) throw new Error('Saved pet clock does not match its checkpoint.');
tape = h.tape; interactions = [...h.interactions]; mode.value = h.source; motion.checked = h.still || media.matches;
source.textContent = h.sourceName;
seek.max = h.source === 'demo' ? '80' : String(Math.max(h.source === 'replay' ? 90 : 120, h.seconds, tape.length * .4));
seek.max = String(Math.max(h.source === 'demo' ? 80 : h.source === 'replay' ? 90 : 120, savedWorld.endTimeMs / 1000));
if (h.source === 'replay') {
mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
}
message.textContent = 'Restoring the saved habitat…';
await rebuild(h.seconds, h.still === motion.checked ? h.checkpoint : undefined);
if (h.checkpoint && h.still === motion.checked) adoptWorld(savedWorld);
else await rebuild(h.seconds, undefined, savedWorld.recording(false).start);
message.textContent = 'Habitat restored locally. Sound starts only when you enable it.';
} else await rebuild();
persistenceReady = true;
persistenceReady = true; void refreshArchives().catch(() => {});
} catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Local persistence is unavailable.'; await rebuild(); }
void refreshArchives().catch(() => {});
setInterval(() => { void persist(); }, 5000); requestAnimationFrame(animate);
} catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to start the habitat.'; }
+22 -1
View File
@@ -5,7 +5,9 @@ export interface SavedHabitat {
petPersistenceVersion: 1; seconds: number; source: 'wild' | 'demo' | 'replay';
sourceName: string; still: boolean; tape: readonly PetBucket[]; interactions: readonly PetInteraction[];
checkpoint?: PetWorldCheckpoint; expressionVersion?: 1 | 2;
petReplayVersion?: 1 | 2; start?: PetWorldCheckpoint;
}
export interface PetArchive { key: number; name: string; seconds: number; startSeconds: number }
export interface HabitatEntry { revision: number; habitat: SavedHabitat }
export interface SavedReference { key: string; savedAt: string }
export interface SavedSummary extends SavedReference { name: string; events: number; privacy: Trace['privacy']; source: Trace['source'] }
@@ -133,10 +135,11 @@ export class TraceLibrary {
const request = habitats.get('pet'); request.onsuccess = () => done(request.result);
});
}
async saveHabitat(habitat: SavedHabitat, expectedRevision?: number): Promise<number> {
async saveHabitat(habitat: SavedHabitat, expectedRevision?: number, archive?: SavedHabitat): Promise<number> {
// Freeze the whole recording/checkpoint together before the first IDB await.
// A live world's arrays may otherwise advance while the transaction opens.
habitat = structuredClone(habitat);
if (archive) archive = structuredClone(archive);
return this.transaction('readwrite', (_traces, _metas, done, fail, habitats) => {
const request = habitats.get('pet');
request.onsuccess = () => {
@@ -145,8 +148,26 @@ export class TraceLibrary {
fail(new Error('Another pet tab saved this habitat. Save a replay file to keep this version, or reload the saved habitat.')); return;
}
const revision = (previous?.revision ?? 0) + 1;
if (archive) {
habitats.add(archive, `pet-archive-${revision}`);
habitats.add({ key: revision, name: archive.sourceName, seconds: archive.seconds,
startSeconds: (archive.start?.frame.timeMs ?? 0) / 1000 }, `pet-archive-meta-${revision}`);
}
habitats.put({ revision, habitat }, 'pet'); done(revision);
};
});
}
async petArchives(): Promise<PetArchive[]> {
return this.transaction('readonly', (_traces, _metas, done, _fail, habitats) => {
const request = habitats.getAll(IDBKeyRange.bound('pet-archive-meta-', 'pet-archive-meta-\uffff'));
request.onsuccess = () => done((request.result as PetArchive[]).sort((a, b) => b.key - a.key));
});
}
async petArchive(key: number): Promise<SavedHabitat> {
if (!Number.isSafeInteger(key) || key < 1) throw new Error('Invalid recording archive.');
return this.transaction('readonly', (_traces, _metas, done, fail, habitats) => {
const request = habitats.get(`pet-archive-${key}`);
request.onsuccess = () => request.result ? done(request.result) : fail(new Error('This recording is unavailable.'));
});
}
}
+49 -20
View File
@@ -6,6 +6,7 @@ import Darwin
/// lock and content revision preserve a newer or damaged file on every failure.
final class PetHabitatStore {
static let limit = 8 * 1024 * 1024
private let url: URL
private let directory: Int32
private let lock: Int32
private let name: String
@@ -23,7 +24,7 @@ final class PetHabitatStore {
guard file >= 0 else { let error = Self.ioError(); close(root); throw error }
do { _ = try Self.regular(file) }
catch { close(file); close(root); throw error }
self.directory = root; self.lock = file; self.name = source + ".json"; self.lockName = key
self.url = url; self.directory = root; self.lock = file; self.name = source + ".json"; self.lockName = key
}
deinit { close(lock); close(directory) }
@@ -33,29 +34,57 @@ final class PetHabitatStore {
return value
}
func save(_ value: Data) throws {
func save(_ value: Data, archive: Data? = nil, tick: Int = 0) throws {
guard loaded, value.count <= Self.limit else { throw Self.invalid("The pet habitat cannot be saved within its size limit.") }
try coordinated {
let current = try read().map { Data(SHA256.hash(data: $0)) }
guard current == expected else { throw Self.invalid("Another writer changed the saved habitat.") }
let temporary = ".pet-" + UUID().uuidString
let file = openat(directory, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600)
guard file >= 0 else { throw Self.ioError() }
defer { close(file); unlinkat(directory, temporary, 0) }
try value.withUnsafeBytes { bytes in
var offset = 0
while offset < bytes.count {
let written = Darwin.write(file, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
if written < 0 && errno == EINTR { continue }
guard written > 0 else { throw Self.ioError() }
offset += written
}
if let archive {
guard archive.count <= 64 * 1024 * 1024, tick >= 0 else { throw Self.invalid("Invalid recording archive.") }
let digest = SHA256.hash(data: archive).map { String(format: "%02x", $0) }.joined()
let key = name.dropLast(5) + "-segment-" + String(format: "%012lld", Int64(tick)) + "-" + digest + ".json"
try write(archive, name: key, immutable: true)
}
guard fsync(file) == 0, renameat(directory, temporary, directory, name) == 0 else { throw Self.ioError() }
try write(value, name: name, immutable: false)
}
expected = Data(SHA256.hash(data: value))
}
func archives() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: url.path).filter(validArchive).sorted(by: >)
}
func archivedRecording(_ key: String) throws -> Data {
guard validArchive(key) else { throw Self.invalid("Invalid recording archive.") }
guard let value = try read(name: key, limit: 64 * 1024 * 1024) else { throw Self.invalid("This recording is unavailable.") }
let digest = SHA256.hash(data: value).map { String(format: "%02x", $0) }.joined()
guard key.hasSuffix("-" + digest + ".json") else { throw Self.invalid("The archived recording was changed. Its file was kept.") }
return value
}
private func validArchive(_ key: String) -> Bool {
key.range(of: "^" + name.dropLast(5) + "-segment-[0-9]{12}-[a-f0-9]{64}\\.json$", options: .regularExpression) != nil
}
private func write(_ value: Data, name: String, immutable: Bool) throws {
let temporary = ".pet-" + UUID().uuidString
let file = openat(directory, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600)
guard file >= 0 else { throw Self.ioError() }
defer { close(file); unlinkat(directory, temporary, 0) }
try value.withUnsafeBytes { bytes in
var offset = 0
while offset < bytes.count {
let written = Darwin.write(file, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
if written < 0 && errno == EINTR { continue }
guard written > 0 else { throw Self.ioError() }
offset += written
}
}
guard fsync(file) == 0 else { throw Self.ioError() }
if immutable {
if linkat(directory, temporary, directory, name, 0) != 0 {
guard errno == EEXIST, try read(name: name, limit: 64 * 1024 * 1024) == value else { throw Self.ioError() }
}
} else if renameat(directory, temporary, directory, name) != 0 { throw Self.ioError() }
}
private func coordinated<T>(_ action: () throws -> T) throws -> T {
guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { throw Self.ioError() }
defer { flock(lock, LOCK_UN) }
@@ -67,21 +96,21 @@ final class PetHabitatStore {
return try action()
}
private func read() throws -> Data? {
let file = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
private func read(name key: String? = nil, limit: Int = PetHabitatStore.limit) throws -> Data? {
let file = openat(directory, key ?? name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
if file < 0 && errno == ENOENT { return nil }
guard file >= 0 else { throw Self.ioError() }
defer { close(file) }
let info = try Self.regular(file)
guard info.st_size >= 0 && info.st_size <= Self.limit else { throw Self.invalid("The saved habitat exceeds 8 MiB.") }
guard info.st_size >= 0 && info.st_size <= limit else { throw Self.invalid("The saved habitat exceeds 8 MiB.") }
var value = Data(), buffer = [UInt8](repeating: 0, count: 16_384)
while true {
let count = Darwin.read(file, &buffer, min(buffer.count, Self.limit + 1 - value.count))
let count = Darwin.read(file, &buffer, min(buffer.count, limit + 1 - value.count))
if count < 0 && errno == EINTR { continue }
guard count >= 0 else { throw Self.ioError() }
if count == 0 { break }
value.append(contentsOf: buffer.prefix(count))
guard value.count <= Self.limit else { throw Self.invalid("The saved habitat exceeds 8 MiB.") }
guard value.count <= limit else { throw Self.invalid("The saved habitat exceeds its size limit.") }
}
return value
}
+19 -2
View File
@@ -9,6 +9,12 @@ public enum PetSource: String, CaseIterable, Identifiable {
public var label: String { switch self { case .wild: return "Wild"; case .demo: return "Event demo"; case .live: return "Live" } }
}
public func petArchiveLabel(_ name: String) -> String {
let tick = Double(name.split(separator: "-").dropLast().last ?? "") ?? 0
let seconds = Int(tick / 30)
return String(format: "Through %d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60)
}
/// Thin host: settings, fixed-rate ticks, lifecycle and file delivery. The shared
/// world owns behaviour, accepted telemetry, interactions and score scheduling.
@MainActor public final class PetHost: ObservableObject {
@@ -16,6 +22,7 @@ public enum PetSource: String, CaseIterable, Identifiable {
@Published public private(set) var message = ""
@Published public private(set) var persistenceMessage = ""
@Published public private(set) var source: PetSource = .wild
@Published public private(set) var archives: [String] = []
@Published public var still = false { didSet { defaults.set(still, forKey: "pet.still") } }
@Published public var sound = false { didSet { defaults.set(sound, forKey: "pet.sound"); configureSound() } }
public var systemReducedMotion = false
@@ -24,6 +31,7 @@ public enum PetSource: String, CaseIterable, Identifiable {
private let defaults: UserDefaults
private let storageDirectory: URL
private var store: PetHabitatStore?
private var archiveStore: PetHabitatStore?
private var migratingLegacy = false
private let audio = PetAudioOutput()
private var timer: Timer?
@@ -57,7 +65,7 @@ public enum PetSource: String, CaseIterable, Identifiable {
}
private func restart() {
audio.stop(); restoring = false; failed = false; monitor?.cancel(); fileMonitor?.cancel(); monitor = nil; fileMonitor = nil; lastPacket = nil; lastSourceSequence = -1; fileIdentity = nil
store = nil; persistenceMessage = ""; migratingLegacy = false; count = 0
store = nil; archiveStore = nil; archives = []; persistenceMessage = ""; migratingLegacy = false; count = 0
do {
guard let script = bundle.url(forResource: "pet-native", withExtension: "js") else { throw PetCoreError.invalid("The shared pet core is missing from this app.") }
var tape = ""
@@ -68,6 +76,7 @@ public enum PetSource: String, CaseIterable, Identifiable {
var saved: Data?
do {
let files = try PetHabitatStore(directory: storageDirectory, source: source.rawValue)
archiveStore = files; archives = (try? files.archives()) ?? []
saved = try files.load(); store = files
} catch { persistenceMessage = "Habitat storage is unavailable. Existing files were kept. This visit stays in memory." }
let legacy = source == .wild && saved == nil && store != nil
@@ -120,6 +129,10 @@ public enum PetSource: String, CaseIterable, Identifiable {
guard !restoring, let core else { throw PetCoreError.invalid("Wait for the habitat to finish opening before exporting.") }
return try core.exportRecording()
}
public func archivedRecording(_ name: String) throws -> Data {
guard let archiveStore else { throw PetCoreError.invalid("Habitat storage is unavailable.") }
return try archiveStore.archivedRecording(name)
}
private func configureSound() {
do { try audio.setEnabled(sound && !paused && !failed && !restoring, simulationTime: (core?.frame.timeMs ?? 0) / 1000) }
catch { message = "Sound unavailable: \(error.localizedDescription)" }
@@ -137,7 +150,11 @@ public enum PetSource: String, CaseIterable, Identifiable {
guard let core else { return true }
guard let store, !restoring, !failed else { return false }
do {
try store.save(core.recording(checkpoint: true))
if let next = try core.prepareSegment() {
let archive = try core.exportRecording(completed: true)
try store.save(next, archive: archive, tick: Int((core.frame.timeMs * 30 / 1000).rounded()))
try core.commitSegment(); archives = (try? store.archives()) ?? archives
} else { try store.save(core.recording(checkpoint: true)) }
if migratingLegacy {
defaults.removeObject(forKey: "pet.interactions"); defaults.removeObject(forKey: "pet.elapsed"); migratingLegacy = false
}
+15 -2
View File
@@ -94,13 +94,26 @@ public enum PetCoreError: Error, LocalizedError {
}
return Data(value.utf8)
}
public func prepareSegment() throws -> Data? {
failure = nil
guard let needed = world.invokeMethod("needsSegment", withArguments: []), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to inspect recording history.") }
if !needed.toBool() { return nil }
guard let text = world.invokeMethod("prepareSegment", withArguments: [])?.toString(), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to prepare recording history.") }
let data = Data(text.utf8)
guard data.count <= 8 * 1024 * 1024 else { throw PetCoreError.invalid("The active recording exceeds 8 MiB.") }
return data
}
public func commitSegment() throws {
failure = nil; world.invokeMethod("commitSegment", withArguments: [])
if let failure { throw PetCoreError.invalid(failure) }
}
/// The host owns the world for this entire export. The private autosave and
/// native import remain bounded to 8 MiB; larger exports open in the browser.
public func exportRecording() throws -> Data {
public func exportRecording(completed: Bool = false) throws -> Data {
var data = Data(), index = 0
while true {
failure = nil
guard let value = world.invokeMethod("recordingChunk", withArguments: [index]), failure == nil else {
guard let value = world.invokeMethod("recordingChunk", withArguments: [index, completed]), failure == nil else {
throw PetCoreError.invalid(failure ?? "Unable to export the pet recording.")
}
if value.isNull { return data }
+21 -2
View File
@@ -166,7 +166,26 @@ import Darwin
long.suspend(true)
try require(long.core!.frame.timeMs >= 7_200_000 && long.core!.frame.state.observed == 0, "Large native habitat did not resume unknown")
try require(long.persistenceMessage.isEmpty, "Large native habitat failed to save")
print("PASS actual Apple host: two-hour 5.54 MB synthetic unknown habitat restored and saved in \(start.duration(to: .now))")
print("PASS 9 Apple checkpoint workflows; fixtures retained at \(root.path)")
let activeData = try Data(contentsOf: longDir.appendingPathComponent("live.json"))
try require(activeData.count < 1024 * 1024 && long.archives.count == 1, "Long history was not archived before compacting the active habitat")
let archived = try long.archivedRecording(long.archives[0])
try require(archived.count > 5 * 1024 * 1024, "Earlier telemetry was not retained")
let compact = try PetNativeCore(points: points, bundle: script, saved: activeData)
try require(compact.frame.digest == long.core!.frame.digest && compact.frame.timeMs == long.core!.frame.timeMs, "Compaction changed the current world")
for _ in 0..<60 { try require(compact.tick(motion: false).digest == long.core!.tick(motion: false).digest, "Compacted continuation diverged") }
print("PASS actual Apple host: two-hour history archived (\(archived.count) bytes), active habitat \(activeData.count) bytes, exact continuation; \(start.duration(to: .now))")
let conflictDir = root.appendingPathComponent("archive-conflict")
try FileManager.default.createDirectory(at: conflictDir, withIntermediateDirectories: true)
try FileManager.default.copyItem(at: out.appendingPathComponent("long-habitat.json"), to: conflictDir.appendingPathComponent("live.json"))
let conflict = PetHost(points: points, bundle: bundle, defaults: defaults, storageDirectory: conflictDir)
let beforeConflict = try conflict.core!.recording(checkpoint: true)
try external.write(to: conflictDir.appendingPathComponent("live.json"))
conflict.suspend(true)
try require(!conflict.persistenceMessage.isEmpty && conflict.archives.isEmpty, "A conflicted archive was committed")
try require(same(beforeConflict, conflict.core!.recording(checkpoint: true)), "Failed archive retired live history")
try require(Data(contentsOf: conflictDir.appendingPathComponent("live.json")) == external, "Archiving replaced another writer")
print("PASS actual Apple host: archive conflict preserves all live history and the other writer's file")
print("PASS 10 Apple checkpoint workflows; fixtures retained at \(root.path)")
}
}
+66
View File
@@ -16,6 +16,72 @@ const equal = (a, b) => {
assert.deepEqual(a.voices, b.voices);
};
test('recording segments preserve exact continuation and replay while retiring only durably archived input', () => {
const reference = new PetWorld(points, tape), world = new PetWorld(points, tape, [], 2, true);
let prepared, archive, archivedEnd;
for (let tick = 0; tick < 1400; tick++) {
if (tick % 97 === 0) { reference.interact('food', .2, -.15); world.interact('food', .2, -.15); }
if (tick === 600) {
archive = JSON.parse(JSON.stringify(world.recording())); archivedEnd = JSON.parse(JSON.stringify(world.checkpoint()));
const before = JSON.stringify(world.recording()); prepared = world.prepareSegment();
assert.equal(JSON.stringify(world.recording()), before, 'preparation cannot retire history before storage succeeds');
archive = structuredClone(prepared.archive);
assert.ok(archive.tape.every(b => b.simTimeMs <= world.frame.timeMs), 'future telemetry belongs to the active segment, not every archive');
assert.ok(archive.interactions.every(e => e.timeMs <= world.frame.timeMs));
const pieces = []; for (let i = 0; ; i++) { const part = world.recordingChunk(i, true); if (part === null) break; pieces.push(part); }
assert.equal(pieces.join(''), JSON.stringify(archive));
equal(PetWorld.fromRecording(points, prepared.recording), world);
}
if (tick === 609) {
prepared.commit(); equal(world, reference);
assert.ok(world.tape[0].sequence > 0); assert.ok(world.interactions.length < reference.interactions.length);
assert.throws(() => prepared.commit(), /changed/);
const restored = PetWorld.fromRecording(points, JSON.parse(JSON.stringify(world.recording())));
equal(restored, world);
}
const opts = { motion: motion(tick), sensitivity: 1 };
reference.step(1 / 30, opts); world.step(1 / 30, opts); equal(world, reference);
}
const replay = PetWorld.fromRecording(points, { ...world.recording(), checkpoint: undefined });
for (let tick = Math.round(replay.frame.timeMs * 30 / 1000); tick < 1400; tick++) replay.step(1 / 30, { motion: motion(tick), sensitivity: 1 });
equal(replay, world);
const earlier = PetWorld.fromRecording(points, archive);
assert.deepEqual(earlier.sim.checkpoint(), archivedEnd.sim);
assert.equal(earlier.frame.timeMs, 20_000);
const chunks = []; for (let i = 0; ; i++) { const chunk = world.recordingChunk(i); if (chunk === null) break; chunks.push(chunk); }
assert.equal(chunks.join(''), JSON.stringify(world.recording()));
for (const edit of [r => delete r.start, r => r.start.history++, r => r.start.historyStart++,
r => r.checkpoint.historyStart++, r => r.petReplayVersion = 1, r => r.start.sim.expressionVersion = 1]) {
const corrupt = structuredClone(world.recording()); edit(corrupt);
assert.throws(() => PetWorld.fromRecording(points, corrupt));
}
});
test('native rotation retains a bounded active history and does not retrigger old approval or score state', () => {
const live = new PetNative(JSON.stringify(points), '', '[]', true);
let archived = 0, largest = 0;
for (let step = 0; step < 180; step++) {
const at = step * 5000;
live.observeEngine(JSON.stringify({ event: 'tool_call_started', tool_call_id: String(step), tool_name: 'exec_command' }), at);
live.advanceEngine(at + 5000, false, false);
largest = Math.max(largest, JSON.parse(live.recording()).tape.length);
if (live.needsSegment()) {
const before = live.snapshot(), outgoing = live.recording(true), next = live.prepareSegment();
assert.equal(live.recording(true), outgoing);
const restored = new PetNative(JSON.stringify(points)); restored.restoreRecording(next);
assert.equal(restored.snapshot(), before);
live.commitSegment(); assert.equal(live.snapshot(), before); archived++;
}
}
assert.equal(archived, 2); assert.ok(largest <= 1040);
const resumed = new PetNative(JSON.stringify(points)); resumed.restoreRecording(live.recording(true));
assert.equal(resumed.snapshot(), live.snapshot());
const offset = resumed.resumeEngine(); resumed.advanceEngine(offset + 800, false, true);
assert.equal(JSON.parse(resumed.snapshot()).state.observed, 0);
assert.equal(JSON.parse(resumed.snapshot()).needs, 'none');
assert.ok(Buffer.byteLength(resumed.recording(true)) < 1024 * 1024);
});
test('chunked exports preserve the complete versioned recording and exact checkpoint beyond the autosave bound', () => {
for (const count of [0, 1, 16, 17, 32_000]) {
const tape = count ? compilePetTelemetry([], count * 400) : [];