mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
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:
1 parent
3972a9ca89
commit
5c70db41f0
29 files changed
+988
-263
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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) : [];
|
||||
|
||||
Reference in new issue
Block a user