diff --git a/package-lock.json b/package-lock.json index 342940b..9cc64fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "apps/stage": { "name": "@flybrain/stage", "version": "0.1.1", + "license": "Apache-2.0", "dependencies": { "@flybrain/brain": "*", "@flybrain/feed": "*", @@ -892,6 +893,10 @@ "resolved": "packages/feed", "link": true }, + "node_modules/@flybrain/session-types": { + "resolved": "packages/session-types", + "link": true + }, "node_modules/@flybrain/stage": { "resolved": "apps/stage", "link": true @@ -3666,6 +3671,7 @@ "packages/brain": { "name": "@flybrain/brain", "version": "0.1.1", + "license": "Apache-2.0", "devDependencies": { "@types/node": "22.17.0", "@types/three": "0.178.1", @@ -3685,6 +3691,7 @@ "packages/feed": { "name": "@flybrain/feed", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "ws": "8.21.3" }, @@ -3696,9 +3703,20 @@ "typescript": "5.9.2" } }, + "packages/session-types": { + "name": "@flybrain/session-types", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "22.17.0", + "tsx": "4.20.3", + "typescript": "5.9.2" + } + }, "services/bridge": { "name": "@flybrain/bridge", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "@flybrain/feed": "0.1.0", "@twurple/api": "8.1.4", diff --git a/packages/session-types/README.md b/packages/session-types/README.md new file mode 100644 index 0000000..c49c758 --- /dev/null +++ b/packages/session-types/README.md @@ -0,0 +1,76 @@ +# @flybrain/session-types + +The session framework contracts in TypeScript: types, validation, canonical JSON (RFC 8785) +and canonical digests. + +The other half of [`services/flysim/crates/fly-session-types`](../../services/flysim/crates/fly-session-types). +Same rules, same canonical bytes, same digests, and the same fixture corpus: this package +loads the crate's `fixtures/` directory rather than keeping a copy, so a case written once +holds both languages to it. Nothing here opens a socket; it reads, validates and hashes. + +This is the internal session path (`docs/design/session-framework/`). The public feed and +control contracts are unchanged and still live in [`@flybrain/feed`](../feed). + +## Modules + +| Module | Contents | +| --- | --- | +| `canonical` | `canonicalize`, `digestOf`, `parseStrict`, `requireEnvelopeFit`, `rejectBusIdentities` | +| `scalar` | `Id`, `U64`, `Digest`, `Scope`, `RationalNs` with checked arithmetic, and the four identities as branded types | +| `reader` | `Reader`, which reads one object field by field and then refuses any field it did not read | +| `common` | `readScope`, `readSchemaRef`, `readTypedValue`, `operationKeyDigest`, `bodyDigest` | +| `media` | View and audio descriptors and refs, and the `State.*` payloads | +| `workers` | The closed enums and every Agent/Environment/Worker method payload | +| `rpc` | `SessionRpcRequest`, the success and failure replies, `ErrorCode`, `MutationCertainty` | +| `publishing` | `SessionDescriptor`, `CommittedSnapshot` | +| `trace` | The step-v1 section 8 record and the behaviour-only comparator | +| `seed` | `seed-derivation-v1` | +| `checkpoint` | The `FLYSESS1` envelope layout | +| `fixtures` | Loading the shared corpus | + +## Reading a payload + +Every reader takes `unknown`, validates, and hands back a value whose fields are exactly the +ones it read. A payload with an unknown or misspelled field fails instead of silently +defaulting, and a round trip through a reader is the test that no field is dropped. + +```ts +import { canonicalize, digestOf, readScope, readPrepareParams, bodyDigest } from '@flybrain/session-types'; + +const scope = readScope(payload.scope); +const params = readPrepareParams(payload.params); +const digest = bodyDigest('Agent.Prepare', scope, params); // the ipc-v1 section 5 comparison +``` + +Rules that need another value in hand are separate functions, because a payload cannot check +them alone: `validatePortControlAgainst`, `validateBatch`, `validateSensoryInputAgainst`, +`validateObservationAgainst`, `validateStepResultAgainst`, `validateSnapshotAgainst`, +`validateTelemetryRoles`, `validateRemainder`, `validateCommitAgainstScope`. + +## Canonical JSON + +Three rules make the two implementations agree byte for byte: + +- object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does; +- numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires; +- a number is canonicalizable when it is finite and, if integral, no larger in magnitude than + `Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every counter + and clock in these contracts is a `U64` decimal string. The rule is on the value, not on how + it was written, because `JSON.parse` cannot tell `1e21` from the same digits written out. + +`parseStrict` is a small recursive-descent parser rather than a wrapper around `JSON.parse`, +which keeps the last of two duplicate keys instead of failing. + +Digests use `node:crypto`. This package is contract tooling for services and tests, not +browser code; the presentation layer consumes the public feed package instead. + +## Tests + +```sh +npm test --workspace @flybrain/session-types +npm run typecheck --workspace @flybrain/session-types +``` + +Nine files, all fixture-driven. The one that says the most about the two implementations is in +`tests/checkpoint.test.ts`: a `FLYSESS1` envelope written here is byte-identical to the one the +Rust crate wrote into the fixture. diff --git a/packages/session-types/package.json b/packages/session-types/package.json new file mode 100644 index 0000000..6a388f4 --- /dev/null +++ b/packages/session-types/package.json @@ -0,0 +1,22 @@ +{ + "name": "@flybrain/session-types", + "version": "0.1.0", + "description": "TypeScript types, validation, canonical JSON (RFC 8785) and canonical digests for the session framework contracts, sharing the fixture corpus of services/flysim/crates/fly-session-types.", + "license": "Apache-2.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "node --import tsx --test tests/**/*.test.ts", + "typecheck": "tsc -p tsconfig.json --pretty false" + }, + "devDependencies": { + "@types/node": "22.17.0", + "tsx": "4.20.3", + "typescript": "5.9.2" + } +} diff --git a/packages/session-types/src/canonical.ts b/packages/session-types/src/canonical.ts new file mode 100644 index 0000000..1e43c51 --- /dev/null +++ b/packages/session-types/src/canonical.ts @@ -0,0 +1,381 @@ +/** + * Canonical JSON (RFC 8785), strict parsing and canonical digests. + * + * The Rust crate `services/flysim/crates/fly-session-types` is the other half of this + * contract; `fixtures/valid.json` records the canonical bytes and digest of every accepted + * payload, and both languages assert against it. + * + * Three rules make the two agree: + * + * - object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does; + * - numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires; + * - a number is canonicalizable when it is finite and, if integral, no larger in magnitude + * than `Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every + * counter and clock in these contracts is a `U64` decimal string. The rule is on the value, + * not on how it was written, because `JSON.parse` cannot tell `1e21` from the same digits + * written out. + */ +import { createHash } from 'node:crypto'; + +/** The largest JSON envelope, in bytes (bus-v1 section 4). */ +export const MAX_ENVELOPE_BYTES = 65_536; + +/** Thrown by everything in this package. One error type, like the bus's `WireError`. */ +export class ContractError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContractError'; + } +} + +export function fail(message: string): never { + throw new ContractError(message); +} + +/** A JSON value, as strictly parsed. */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +/** `String(number)` for a canonicalizable number. */ +function numberToString(value: number): string { + if (!Number.isFinite(value)) { + fail(`canonical JSON: ${String(value)} is not a finite number`); + } + if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) { + fail(`canonical JSON: ${String(value)} is an integral value outside the exact double range`); + } + // String(-0) is already "0". + return String(value); +} + +function writeString(out: string[], value: string): void { + out.push('"'); + for (const character of value) { + switch (character) { + case '"': + out.push('\\"'); + break; + case '\\': + out.push('\\\\'); + break; + case '\b': + out.push('\\b'); + break; + case '\t': + out.push('\\t'); + break; + case '\n': + out.push('\\n'); + break; + case '\f': + out.push('\\f'); + break; + case '\r': + out.push('\\r'); + break; + default: { + const point = character.codePointAt(0) ?? 0; + if (point < 0x20) { + out.push(`\\u${point.toString(16).padStart(4, '0')}`); + } else { + out.push(character); + } + } + } + } + out.push('"'); +} + +function write(out: string[], value: unknown): void { + if (value === null) { + out.push('null'); + return; + } + switch (typeof value) { + case 'boolean': + out.push(value ? 'true' : 'false'); + return; + case 'number': + out.push(numberToString(value)); + return; + case 'string': + writeString(out, value); + return; + case 'object': + break; + default: + fail(`canonical JSON: ${typeof value} is not a JSON value`); + } + if (Array.isArray(value)) { + out.push('['); + value.forEach((item, index) => { + if (index > 0) out.push(','); + write(out, item); + }); + out.push(']'); + return; + } + const entries = Object.entries(value as Record); + for (const [key, item] of entries) { + if (item === undefined) fail(`canonical JSON: ${key} is undefined, which is not a JSON value`); + } + // Comparing JavaScript strings compares UTF-16 code units, which is the order RFC 8785 + // section 3.2.3 specifies. + entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + out.push('{'); + entries.forEach(([key, item], index) => { + if (index > 0) out.push(','); + writeString(out, key); + out.push(':'); + write(out, item); + }); + out.push('}'); +} + +/** The canonical JSON text of `value`. */ +export function canonicalize(value: unknown): string { + const out: string[] = []; + write(out, value); + return out.join(''); +} + +/** Lowercase hex SHA-256 of `bytes`. */ +export function sha256Hex(bytes: Uint8Array | string): string { + return createHash('sha256') + .update(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes) + .digest('hex'); +} + +/** The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes. */ +export function digestOf(value: unknown): string { + return sha256Hex(canonicalize(value)); +} + +/** + * Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, `NaN`/`Infinity`, + * trailing bytes and control characters inside strings are all refused. + * + * `JSON.parse` keeps the last of two duplicate keys instead of failing, so this is a small + * recursive-descent parser rather than a wrapper around it. + */ +export function parseStrict(input: Uint8Array | string): Json { + let text: string; + if (typeof input === 'string') { + text = input; + } else { + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(input); + } catch { + return fail('invalid UTF-8'); + } + } + const parser = new Parser(text); + const value = parser.value(); + parser.skipWhitespace(); + if (!parser.atEnd()) fail('invalid JSON: trailing data'); + return value; +} + +class Parser { + private index = 0; + + constructor(private readonly text: string) {} + + atEnd(): boolean { + return this.index >= this.text.length; + } + + skipWhitespace(): void { + while (this.index < this.text.length && ' \t\n\r'.includes(this.text[this.index] as string)) { + this.index += 1; + } + } + + value(): Json { + this.skipWhitespace(); + const character = this.text[this.index]; + if (character === undefined) fail('invalid JSON: unexpected end of input'); + switch (character) { + case '{': + return this.object(); + case '[': + return this.array(); + case '"': + return this.string(); + case 't': + this.literal('true'); + return true; + case 'f': + this.literal('false'); + return false; + case 'n': + this.literal('null'); + return null; + default: + return this.number(); + } + } + + private literal(word: string): void { + if (!this.text.startsWith(word, this.index)) fail(`invalid JSON: expected ${word}`); + this.index += word.length; + } + + private object(): Json { + this.index += 1; + const out: { [key: string]: Json } = {}; + this.skipWhitespace(); + if (this.text[this.index] === '}') { + this.index += 1; + return out; + } + for (;;) { + this.skipWhitespace(); + if (this.text[this.index] !== '"') fail('invalid JSON: expected a key'); + const key = this.string(); + if (Object.prototype.hasOwnProperty.call(out, key)) { + fail(`invalid JSON: duplicate key ${JSON.stringify(key)}`); + } + this.skipWhitespace(); + if (this.text[this.index] !== ':') fail('invalid JSON: expected :'); + this.index += 1; + out[key] = this.value(); + this.skipWhitespace(); + const next = this.text[this.index]; + if (next === ',') { + this.index += 1; + continue; + } + if (next === '}') { + this.index += 1; + return out; + } + fail('invalid JSON: expected , or }'); + } + } + + private array(): Json { + this.index += 1; + const out: Json[] = []; + this.skipWhitespace(); + if (this.text[this.index] === ']') { + this.index += 1; + return out; + } + for (;;) { + out.push(this.value()); + this.skipWhitespace(); + const next = this.text[this.index]; + if (next === ',') { + this.index += 1; + continue; + } + if (next === ']') { + this.index += 1; + return out; + } + fail('invalid JSON: expected , or ]'); + } + } + + private string(): string { + this.index += 1; + let out = ''; + for (;;) { + const character = this.text[this.index]; + if (character === undefined) fail('invalid JSON: unterminated string'); + this.index += 1; + if (character === '"') return out; + if (character === '\\') { + const escape = this.text[this.index]; + this.index += 1; + switch (escape) { + case '"': + case '\\': + case '/': + out += escape; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case 'u': { + const hex = this.text.slice(this.index, this.index + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid JSON: bad \\u escape'); + out += String.fromCharCode(Number.parseInt(hex, 16)); + this.index += 4; + break; + } + default: + fail('invalid JSON: bad escape'); + } + continue; + } + if (character.charCodeAt(0) < 0x20) fail('invalid JSON: control character in a string'); + out += character; + } + } + + private number(): number { + const match = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/.exec( + this.text.slice(this.index), + ); + if (!match) fail('invalid JSON: expected a value'); + this.index += match[0].length; + const value = Number(match[0]); + if (!Number.isFinite(value)) fail('invalid JSON: non-finite number'); + return value; + } +} + +/** + * Refuses a domain payload that does not fit the bus envelope ceiling. `envelopeOverhead` is + * what the surrounding envelope adds, so a payload that only fits without its envelope fails. + */ +export function requireEnvelopeFit(value: unknown, envelopeOverhead: number): number { + const total = canonicalize(value).length + envelopeOverhead; + if (total > MAX_ENVELOPE_BYTES) { + fail(`envelope: ${total} bytes exceeds the ${MAX_ENVELOPE_BYTES}-byte maximum`); + } + return total; +} + +/** Keys that belong to the bus and never to a domain body (ipc-v1 section 5). */ +export const BUS_ONLY_KEYS = [ + 'callId', + 'deliveryId', + 'ownerId', + 'ownerIds', + 'deliveryIds', + 'requestDeliveryId', + 'expectedIncarnation', + 'serviceIncarnation', + 'connectionId', + 'topicSequence', + 'subscriptionId', +] as const; + +/** Fails if any bus-only key appears anywhere in `value`. */ +export function rejectBusIdentities(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) rejectBusIdentities(item); + return; + } + if (value === null || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value as Record)) { + if ((BUS_ONLY_KEYS as readonly string[]).includes(key)) { + fail(`canonical body: "${key}" is a bus identity and never part of a domain body`); + } + rejectBusIdentities(item); + } +} diff --git a/packages/session-types/src/checkpoint.ts b/packages/session-types/src/checkpoint.ts new file mode 100644 index 0000000..63aac19 --- /dev/null +++ b/packages/session-types/src/checkpoint.ts @@ -0,0 +1,273 @@ +/** + * `FLYSESS1`: the envelope layout of + * `docs/design/session-framework/checkpoint-envelope-v1.md`. + * + * The layout half of the specification, not the store: writing generations, fsyncing and + * committing a manifest belong to the STATE-01 slice. `FLYSIM01` is a different format with a + * different magic and is not touched by any of this. + */ +import { createHash } from 'node:crypto'; + +import { type Json, canonicalize, fail, parseStrict } from './canonical'; +import { requireUnique } from './reader'; +import { isId } from './scalar'; + +export const MAGIC = 'FLYSESS1'; +export const FOOTER_MAGIC = 'FLYSESSF'; +export const VERSION = 1; +export const HEADER_BYTES = 32; +export const TABLE_ENTRY_BYTES = 112; +export const NAME_BYTES = 64; +export const FOOTER_BYTES = 48; +export const ALIGNMENT = 8; +export const MAX_PAYLOADS = 64; + +export interface PayloadEntry { + name: string; + offset: number; + byteLength: number; + digest: string; +} + +export interface Layout { + manifestOffset: number; + manifestBytes: number; + tableOffset: number; + entries: PayloadEntry[]; + footerOffset: number; + totalBytes: number; +} + +export interface Envelope { + manifest: Json; + payloads: { name: string; bytes: Uint8Array }[]; + layout: Layout; +} + +function alignUp(value: number): number { + return Math.ceil(value / ALIGNMENT) * ALIGNMENT; +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function layoutOf( + manifest: unknown, + payloads: readonly { name: string; bytes: Uint8Array }[], +): Layout { + if (payloads.length > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads'); + requireUnique( + payloads.map((payload) => payload.name), + 'checkpoint envelope: payload names', + ); + for (const payload of payloads) { + if (!isId(payload.name)) { + fail(`checkpoint envelope: payload name "${payload.name}" is not an Id`); + } + } + const manifestBytes = new TextEncoder().encode(canonicalize(manifest)).length; + const manifestOffset = HEADER_BYTES; + const tableOffset = alignUp(manifestOffset + manifestBytes); + let offset = alignUp(tableOffset + payloads.length * TABLE_ENTRY_BYTES); + const entries: PayloadEntry[] = []; + for (const payload of payloads) { + entries.push({ + name: payload.name, + offset, + byteLength: payload.bytes.length, + digest: sha256(payload.bytes), + }); + offset = alignUp(offset + payload.bytes.length); + } + return { + manifestOffset, + manifestBytes, + tableOffset, + entries, + footerOffset: offset, + totalBytes: offset + FOOTER_BYTES, + }; +} + +export function encode( + manifest: unknown, + payloads: readonly { name: string; bytes: Uint8Array }[], +): Uint8Array { + const layout = layoutOf(manifest, payloads); + const manifestText = new TextEncoder().encode(canonicalize(manifest)); + const out = Buffer.alloc(layout.footerOffset); + out.write(MAGIC, 0, 'ascii'); + out.writeUInt32LE(VERSION, 8); + out.writeUInt32LE(HEADER_BYTES, 12); + out.writeUInt32LE(layout.manifestBytes, 16); + out.writeUInt32LE(payloads.length, 20); + out.writeUInt32LE(layout.tableOffset, 24); + out.writeUInt32LE(0, 28); + Buffer.from(manifestText).copy(out, layout.manifestOffset); + layout.entries.forEach((entry, index) => { + const base = layout.tableOffset + index * TABLE_ENTRY_BYTES; + out.write(entry.name, base, 'ascii'); + out.writeBigUInt64LE(BigInt(entry.offset), base + NAME_BYTES); + out.writeBigUInt64LE(BigInt(entry.byteLength), base + NAME_BYTES + 8); + Buffer.from(entry.digest, 'hex').copy(out, base + NAME_BYTES + 16); + }); + layout.entries.forEach((entry, index) => { + Buffer.from((payloads[index] as { bytes: Uint8Array }).bytes).copy(out, entry.offset); + }); + const footer = Buffer.alloc(FOOTER_BYTES); + footer.writeBigUInt64LE(BigInt(layout.totalBytes), 0); + Buffer.from(sha256(out), 'hex').copy(footer, 8); + footer.write(FOOTER_MAGIC, 40, 'ascii'); + return Buffer.concat([out, footer]); +} + +/** Reads and fully validates one envelope. */ +export function decode(input: Uint8Array): Envelope { + const bytes = Buffer.from(input); + if (bytes.length < HEADER_BYTES + FOOTER_BYTES) { + fail('checkpoint envelope: shorter than a header plus a footer'); + } + if (bytes.subarray(0, 8).toString('ascii') !== MAGIC) { + fail('checkpoint envelope: wrong magic (FLYSIM01 is a different format)'); + } + if (bytes.readUInt32LE(8) !== VERSION) fail('checkpoint envelope: unsupported version'); + if (bytes.readUInt32LE(12) !== HEADER_BYTES) { + fail('checkpoint envelope: headerBytes must be 32'); + } + if (bytes.readUInt32LE(28) !== 0) { + fail('checkpoint envelope: reserved header word must be zero'); + } + const manifestBytes = bytes.readUInt32LE(16); + const payloadCount = bytes.readUInt32LE(20); + const tableOffset = bytes.readUInt32LE(24); + if (payloadCount > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads'); + const footerOffset = bytes.length - FOOTER_BYTES; + if (bytes.subarray(footerOffset + 40).toString('ascii') !== FOOTER_MAGIC) { + fail('checkpoint envelope: missing footer magic'); + } + if (bytes.readBigUInt64LE(footerOffset) !== BigInt(bytes.length)) { + fail('checkpoint envelope: footer length does not match the file'); + } + const recorded = bytes.subarray(footerOffset + 8, footerOffset + 40).toString('hex'); + if (recorded !== sha256(bytes.subarray(0, footerOffset))) { + fail('checkpoint envelope: footer digest does not match the contents'); + } + const manifestEnd = HEADER_BYTES + manifestBytes; + if (manifestEnd > footerOffset) { + fail('checkpoint envelope: manifest runs past the payload area'); + } + const manifestSlice = bytes.subarray(HEADER_BYTES, manifestEnd); + const manifest = parseStrict(manifestSlice); + if (canonicalize(manifest) !== manifestSlice.toString('utf8')) { + fail('checkpoint envelope: the manifest is not canonical JSON'); + } + if (tableOffset !== alignUp(manifestEnd)) { + fail('checkpoint envelope: the payload table is not at its laid-out offset'); + } + const tableEnd = tableOffset + payloadCount * TABLE_ENTRY_BYTES; + if (tableEnd > footerOffset) { + fail('checkpoint envelope: the payload table runs past the payload area'); + } + const entries: PayloadEntry[] = []; + const payloads: { name: string; bytes: Uint8Array }[] = []; + let previousEnd = alignUp(tableEnd); + for (let index = 0; index < payloadCount; index += 1) { + const base = tableOffset + index * TABLE_ENTRY_BYTES; + const nameField = bytes.subarray(base, base + NAME_BYTES); + const terminator = nameField.indexOf(0); + const length = terminator === -1 ? NAME_BYTES : terminator; + if (nameField.subarray(length).some((byte) => byte !== 0)) { + fail('checkpoint envelope: a payload name has bytes after its terminator'); + } + const name = nameField.subarray(0, length).toString('utf8'); + if (!isId(name)) fail(`checkpoint envelope: payload name "${name}" is not an Id`); + const offset = Number(bytes.readBigUInt64LE(base + NAME_BYTES)); + const byteLength = Number(bytes.readBigUInt64LE(base + NAME_BYTES + 8)); + const digest = bytes.subarray(base + NAME_BYTES + 16, base + NAME_BYTES + 48).toString('hex'); + if (offset !== previousEnd) { + fail( + `checkpoint envelope: payload "${name}" starts at ${offset}, not at its aligned ${previousEnd}`, + ); + } + const end = offset + byteLength; + if (end > footerOffset) { + fail(`checkpoint envelope: payload "${name}" runs past the payload area`); + } + const payload = bytes.subarray(offset, end); + if (sha256(payload) !== digest) { + fail(`checkpoint envelope: payload "${name}" fails its digest`); + } + previousEnd = alignUp(end); + entries.push({ name, offset, byteLength, digest }); + payloads.push({ name, bytes: Uint8Array.from(payload) }); + } + requireUnique( + entries.map((entry) => entry.name), + 'checkpoint envelope: payload names', + ); + if (previousEnd !== footerOffset) { + fail('checkpoint envelope: padding between the last payload and the footer'); + } + return { + manifest, + payloads, + layout: { + manifestOffset: HEADER_BYTES, + manifestBytes, + tableOffset, + entries, + footerOffset, + totalBytes: bytes.length, + }, + }; +} + +/** The manifest fields state-media-v1 section 4 requires. */ +export const REQUIRED_MANIFEST_FIELDS = [ + 'envelopeVersion', + 'checkpointId', + 'sourceScope', + 'episodeId', + 'worldTime', + 'schedulerId', + 'compositionDigest', + 'portMap', + 'compatibility', + 'agents', + 'coordinator', + 'payloads', +] as const; + +/** Checks the required field set and that the manifest's payload table mirrors the envelope's. */ +export function validateManifest(envelope: Envelope): void { + const manifest = envelope.manifest; + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) { + fail('checkpoint manifest: must be an object'); + } + const map = manifest as Record; + for (const field of REQUIRED_MANIFEST_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(map, field)) { + fail(`checkpoint manifest: missing "${field}"`); + } + } + if (map.envelopeVersion !== VERSION) fail('checkpoint manifest: envelopeVersion must be 1'); + const listed = map.payloads; + if (!Array.isArray(listed)) fail('checkpoint manifest: payloads must be an array'); + if (listed.length !== envelope.layout.entries.length) { + fail('checkpoint manifest: payloads does not match the payload table'); + } + listed.forEach((declared, index) => { + const entry = envelope.layout.entries[index] as PayloadEntry; + const record = declared as Record; + if (record.name !== entry.name) { + fail('checkpoint manifest: payload name does not match the table'); + } + if (record.byteLength !== String(entry.byteLength)) { + fail(`checkpoint manifest: payload "${entry.name}" byteLength does not match the table`); + } + if (record.digest !== entry.digest) { + fail(`checkpoint manifest: payload "${entry.name}" digest does not match the table`); + } + }); +} diff --git a/packages/session-types/src/common.ts b/packages/session-types/src/common.ts new file mode 100644 index 0000000..c13398a --- /dev/null +++ b/packages/session-types/src/common.ts @@ -0,0 +1,114 @@ +/** + * `Scope`, `SchemaRef`, `TypedValue`, the operation key and the canonical body + * (ipc-v1 sections 2 and 5). + */ +import { canonicalize, digestOf, fail, rejectBusIdentities } from './canonical'; +import { Reader } from './reader'; +import { + MAX_TYPED_VALUE_BYTES, + type RationalNs, + type Scope, + type SchemaRef, + type TypedValue, + isId, + isMethod, + validateRational, +} from './scalar'; + +export function readScope(value: unknown): Scope { + const reader = new Reader(value, 'Scope'); + const scope: Scope = { + sessionId: reader.id('sessionId'), + epoch: reader.id('epoch'), + step: reader.u64('step'), + }; + reader.finish(); + return scope; +} + +export function readNullableScope(value: unknown): Scope | null { + return value === null ? null : readScope(value); +} + +export function readSchemaRef(value: unknown): SchemaRef { + const reader = new Reader(value, 'SchemaRef'); + const schema: SchemaRef = { + id: reader.id('id'), + version: reader.int('version', 1, 65_535), + digest: reader.digest('digest'), + }; + reader.finish(); + return schema; +} + +export function readTypedValue(value: unknown): TypedValue { + const reader = new Reader(value, 'TypedValue'); + const typed: TypedValue = { + schema: readSchemaRef(reader.value('schema')), + value: reader.object('value'), + }; + reader.finish(); + const length = canonicalize(typed).length; + if (length > MAX_TYPED_VALUE_BYTES) { + fail( + `TypedValue: ${length} bytes of canonical JSON exceeds the ${MAX_TYPED_VALUE_BYTES}-byte limit`, + ); + } + return typed; +} + +export function readNullableTypedValue(value: unknown): TypedValue | null { + return value === null ? null : readTypedValue(value); +} + +export { validateRational }; + +/** `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation. */ +export interface OperationKey { + scope: Scope; + method: string; + workerId: string; +} + +export function operationKeyJson(key: OperationKey): Record { + if (!isMethod(key.method)) { + fail('OperationKey: method must be 1..=128 printable ASCII characters'); + } + if (!isId(key.workerId)) fail('OperationKey: workerId is not a valid id'); + return { scope: key.scope, method: key.method, workerId: key.workerId }; +} + +export function operationKeyDigest(key: OperationKey): string { + return digestOf(operationKeyJson(key)); +} + +/** The canonical body of a domain operation: method, scope and validated params. */ +export function canonicalBody( + method: string, + scope: Scope | null, + params: unknown, +): Record { + if (!isMethod(method)) { + fail('canonical body: method must be 1..=128 printable ASCII characters'); + } + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + fail('canonical body: params must be an object'); + } + rejectBusIdentities(params); + return { method, scope, params }; +} + +export function bodyDigest(method: string, scope: Scope | null, params: unknown): string { + return digestOf(canonicalBody(method, scope, params)); +} + +export function readRational(value: unknown): RationalNs { + const reader = new Reader(value, 'RationalNs'); + const rational: RationalNs = { + numerator: reader.u64('numerator'), + denominator: reader.u64('denominator'), + }; + reader.finish(); + validateRational(rational); + return rational; +} diff --git a/packages/session-types/src/fixtures.ts b/packages/session-types/src/fixtures.ts new file mode 100644 index 0000000..87135d5 --- /dev/null +++ b/packages/session-types/src/fixtures.ts @@ -0,0 +1,62 @@ +/** + * Loading the fixture corpus, which lives with the Rust crate: + * `services/flysim/crates/fly-session-types/fixtures`. + * + * One corpus, two implementations. A case written once holds both languages to it. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { type Json, fail, parseStrict } from './canonical'; + +const here = fileURLToPath(new URL('.', import.meta.url)); + +/** The fixture directory. */ +export const FIXTURE_DIR = join( + here, + '../../../services/flysim/crates/fly-session-types/fixtures', +); + +export function loadBytes(name: string): Uint8Array { + return new Uint8Array(readFileSync(join(FIXTURE_DIR, name))); +} + +/** Reads one fixture file, parsed strictly. */ +export function load(name: string): Json { + return parseStrict(loadBytes(name)); +} + +export function section(file: Json, key: string): Json[] { + const value = (file as Record)[key]; + if (!Array.isArray(value) || value.length === 0) { + fail(`fixture: ${key} must be a nonempty array`); + } + return value; +} + +export function cases(file: Json): Json[] { + return section(file, 'cases'); +} + +/** A string field of one case. */ +export function field(value: Json, key: string): string { + const found = (value as Record)[key]; + if (typeof found !== 'string') fail(`fixture case: missing string field "${key}"`); + return found; +} + +export function optionalField(value: Json, key: string): string | undefined { + const found = (value as Record)[key]; + return typeof found === 'string' ? found : undefined; +} + +export function member(value: Json, key: string): Json { + const found = (value as Record)[key]; + if (found === undefined) fail(`fixture case: missing field "${key}"`); + return found; +} + +export function decodeBase64(text: string): Uint8Array { + return new Uint8Array(Buffer.from(text, 'base64')); +} diff --git a/packages/session-types/src/index.ts b/packages/session-types/src/index.ts new file mode 100644 index 0000000..72e96f9 --- /dev/null +++ b/packages/session-types/src/index.ts @@ -0,0 +1,19 @@ +/** + * `@flybrain/session-types`: the session framework contracts in TypeScript. + * + * The other half of `services/flysim/crates/fly-session-types`. Same rules, same canonical + * JSON, same digests, same fixtures. Nothing here opens a socket: it reads, validates and + * hashes payloads. + */ +export * from './canonical'; +export * from './scalar'; +export * from './reader'; +export * from './common'; +export * from './media'; +export * from './workers'; +export * from './rpc'; +export * from './publishing'; +export * from './trace'; +export * as seed from './seed'; +export * as checkpoint from './checkpoint'; +export * as fixtures from './fixtures'; diff --git a/packages/session-types/src/media.ts b/packages/session-types/src/media.ts new file mode 100644 index 0000000..adb1f88 --- /dev/null +++ b/packages/session-types/src/media.ts @@ -0,0 +1,264 @@ +/** Native observation media (state-media-v1 section 2) and the `State.*` payloads (section 5). */ +import { fail } from './canonical'; +import { readRational, readScope } from './common'; +import { Reader, readArtifactRef, requireUnique, u64 } from './reader'; +import { type ArtifactRef, type Digest, type Id, type Scope, type U64, isDigest } from './scalar'; + +/** Max views per sensory input (workers-v1 section 1), and per descriptor list. */ +export const MAX_VIEWS = 8; +export const MAX_VIEW_DIMENSION = 4096; +export const MAX_PIXEL_ASPECT = 65_535; +export const MAX_OBSERVATION_DELAY_STEPS = 8; +export const MAX_SAMPLE_FRAMES = 192_000; +/** Not a stated bound; this crate's choice, published in the schema set. */ +export const MAX_AUDIO_STREAMS = 8; + +export interface ViewDescriptor { + viewId: Id; + width: number; + height: number; + format: 'rgba8'; + rowStride: number; + pixelAspect: { numerator: number; denominator: number }; + observationDelaySteps: number; +} + +export interface ViewRef { + viewId: Id; + producedStep: U64; + pixels: ArtifactRef; +} + +export interface AudioDescriptor { + streamId: Id; + sampleRate: number; + channels: number; + format: 'f32le-interleaved'; +} + +export interface AudioRef { + streamId: Id; + firstSample: U64; + sampleFrames: number; + samples: ArtifactRef; + discontinuity: boolean; +} + +export function readViewDescriptor(value: unknown): ViewDescriptor { + const reader = new Reader(value, 'ViewDescriptor'); + const viewId = reader.id('viewId'); + const width = reader.int('width', 1, MAX_VIEW_DIMENSION); + const height = reader.int('height', 1, MAX_VIEW_DIMENSION); + const format = reader.constant('format', 'rgba8'); + const rowStride = reader.int('rowStride', 1, MAX_VIEW_DIMENSION * 4); + const aspectReader = new Reader(reader.value('pixelAspect'), 'ViewDescriptor.pixelAspect'); + const pixelAspect = { + numerator: aspectReader.int('numerator', 1, MAX_PIXEL_ASPECT), + denominator: aspectReader.int('denominator', 1, MAX_PIXEL_ASPECT), + }; + aspectReader.finish(); + const observationDelaySteps = reader.int( + 'observationDelaySteps', + 0, + MAX_OBSERVATION_DELAY_STEPS, + ); + reader.finish(); + if (rowStride !== width * 4) { + fail('ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)'); + } + return { viewId, width, height, format, rowStride, pixelAspect, observationDelaySteps }; +} + +/** The exact byte length of one frame of this view. */ +export function frameBytes(descriptor: ViewDescriptor): number { + return descriptor.rowStride * descriptor.height; +} + +/** `max(0, boundary - observationDelaySteps)` (state-media-v1 section 2). */ +export function requiredProducedStep(descriptor: ViewDescriptor, boundary: bigint): bigint { + const delay = BigInt(descriptor.observationDelaySteps); + return boundary > delay ? boundary - delay : 0n; +} + +export function readViewRef(value: unknown): ViewRef { + const reader = new Reader(value, 'ViewRef'); + const view: ViewRef = { + viewId: reader.id('viewId'), + producedStep: reader.u64('producedStep'), + pixels: readArtifactRef(reader.value('pixels')), + }; + reader.finish(); + if (u64(view.pixels.byteLength) === 0n) { + fail('ViewRef: pixels must have a positive byte length'); + } + return view; +} + +export function readViewList(reader: Reader, key: string): ViewRef[] { + const views = reader.list(key, 0, MAX_VIEWS, readViewRef); + requireUnique( + views.map((view) => view.viewId), + key, + ); + return views; +} + +export function readAudioDescriptor(value: unknown): AudioDescriptor { + const reader = new Reader(value, 'AudioDescriptor'); + const descriptor: AudioDescriptor = { + streamId: reader.id('streamId'), + sampleRate: reader.int('sampleRate', 8_000, 192_000), + channels: reader.int('channels', 1, 8), + format: reader.constant('format', 'f32le-interleaved'), + }; + reader.finish(); + return descriptor; +} + +export function readAudioRef(value: unknown): AudioRef { + const reader = new Reader(value, 'AudioRef'); + const chunk: AudioRef = { + streamId: reader.id('streamId'), + firstSample: reader.u64('firstSample'), + sampleFrames: reader.int('sampleFrames', 0, MAX_SAMPLE_FRAMES), + samples: readArtifactRef(reader.value('samples')), + discontinuity: reader.boolean('discontinuity'), + }; + reader.finish(); + if (u64(chunk.firstSample) + BigInt(chunk.sampleFrames) > 18446744073709551615n) { + fail('AudioRef: firstSample + sampleFrames overflows U64'); + } + return chunk; +} + +export function readAudioList(reader: Reader, key: string): AudioRef[] { + const audio = reader.list(key, 0, MAX_AUDIO_STREAMS, readAudioRef); + requireUnique( + audio.map((chunk) => chunk.streamId), + key, + ); + return audio; +} + +/** Byte shape and producing boundary against the descriptor that declared this view. */ +export function validateViewAgainst( + view: ViewRef, + descriptor: ViewDescriptor, + boundary: bigint | null, +): void { + if (view.viewId !== descriptor.viewId) { + fail(`ViewRef: viewId "${view.viewId}" does not match descriptor "${descriptor.viewId}"`); + } + if (u64(view.pixels.byteLength) !== BigInt(frameBytes(descriptor))) { + fail( + `ViewRef ${view.viewId}: artifact is ${view.pixels.byteLength} bytes, rowStride x height is ${frameBytes(descriptor)}`, + ); + } + if (boundary !== null) { + const expected = requiredProducedStep(descriptor, boundary); + if (u64(view.producedStep) !== expected) { + fail( + `ViewRef ${view.viewId}: producedStep ${view.producedStep} must be max(0, ${boundary} - ${descriptor.observationDelaySteps}) = ${expected}`, + ); + } + } +} + +export function validateAudioAgainst(chunk: AudioRef, descriptor: AudioDescriptor): void { + if (chunk.streamId !== descriptor.streamId) { + fail(`AudioRef: streamId "${chunk.streamId}" does not match the descriptor`); + } + const expected = BigInt(chunk.sampleFrames) * BigInt(descriptor.channels) * 4n; + if (u64(chunk.samples.byteLength) !== expected) { + fail( + `AudioRef ${chunk.streamId}: artifact is ${chunk.samples.byteLength} bytes, sampleFrames x channels x 4 is ${expected}`, + ); + } +} + +// ---------------------------------------------------------------------------------- State.* + +export interface CaptureParams { + checkpointId: Id; +} + +export interface CaptureResult { + checkpointId: Id; + boundary: U64; + compatibilityDigest: Digest; + payload: ArtifactRef; +} + +export interface StageRestoreParams { + checkpointId: Id; + sourceScope: Scope; + compatibilityDigest: Digest; + payload: ArtifactRef; +} + +export interface StageRestoreResult { + checkpointId: Id; + restoreToken: Id; +} + +export interface ActivateRestoreParams { + restoreToken: Id; +} + +function checkpointPayload(reader: Reader, key: string): ArtifactRef { + const reference = readArtifactRef(reader.value(key)); + if (!isDigest(reference.digest)) { + fail(`${key}: a checkpoint payload must carry a content digest`); + } + return reference; +} + +export function readCaptureParams(value: unknown): CaptureParams { + const reader = new Reader(value, 'CaptureParams'); + const params = { checkpointId: reader.id('checkpointId') }; + reader.finish(); + return params; +} + +export function readCaptureResult(value: unknown): CaptureResult { + const reader = new Reader(value, 'CaptureResult'); + const result: CaptureResult = { + checkpointId: reader.id('checkpointId'), + boundary: reader.u64('boundary'), + compatibilityDigest: reader.digest('compatibilityDigest'), + payload: checkpointPayload(reader, 'payload'), + }; + reader.finish(); + return result; +} + +export function readStageRestoreParams(value: unknown): StageRestoreParams { + const reader = new Reader(value, 'StageRestoreParams'); + const params: StageRestoreParams = { + checkpointId: reader.id('checkpointId'), + sourceScope: readScope(reader.value('sourceScope')), + compatibilityDigest: reader.digest('compatibilityDigest'), + payload: checkpointPayload(reader, 'payload'), + }; + reader.finish(); + return params; +} + +export function readStageRestoreResult(value: unknown): StageRestoreResult { + const reader = new Reader(value, 'StageRestoreResult'); + const result: StageRestoreResult = { + checkpointId: reader.id('checkpointId'), + restoreToken: reader.id('restoreToken'), + }; + reader.finish(); + return result; +} + +export function readActivateRestoreParams(value: unknown): ActivateRestoreParams { + const reader = new Reader(value, 'ActivateRestoreParams'); + const params = { restoreToken: reader.id('restoreToken') }; + reader.finish(); + return params; +} + +export { readRational }; diff --git a/packages/session-types/src/publishing.ts b/packages/session-types/src/publishing.ts new file mode 100644 index 0000000..855460a --- /dev/null +++ b/packages/session-types/src/publishing.ts @@ -0,0 +1,221 @@ +/** The publication types of publishing-v1 section 3. */ +import { fail } from './canonical'; +import { readRational, readScope, readSchemaRef, readTypedValue, readNullableTypedValue } from './common'; +import { + type AudioRef, + MAX_VIEWS, + type ViewRef, + readAudioList, + readViewList, +} from './media'; +import { Reader, requireUnique, u64 } from './reader'; +import { type Digest, type Id, type RationalNs, type SchemaRef, type Scope, type TypedValue, type U64 } from './scalar'; +import { + type AgentTelemetry, + type AssetRef, + type EnvironmentDescriptor, + MAX_AGENTS, + MAX_RATE_ROLES, + type PortControl, + findPort, + readAgentTelemetry, + readAssetRef, + readEnvironmentDescriptor, + readPortControl, + validatePortControlAgainst, + validateTelemetryRoles, +} from './workers'; + +/** Not stated by a document; this crate's choices, published in the schema set. */ +export const MAX_SUPPORTED_STIMULI = 64; +export const MAX_ASSETS = 64; +export const MAX_SNAPSHOT_EVENTS = 64; + +export interface AgentDescriptor { + agentId: Id; + portId: Id; + profileDigest: Digest; + datasetDigest: Digest; + indexDigest: Digest; + neuronCount: U64; + rateRoles: Id[]; + supportedStimuli: Id[]; +} + +export interface SessionDescriptor { + sessionId: Id; + revision: U64; + compositionDigest: Digest; + schedulerId: 'lockstep-v1'; + environment: EnvironmentDescriptor; + taskSchema: SchemaRef; + agents: AgentDescriptor[]; + assets: AssetRef[]; +} + +export interface SnapshotAgent { + agentId: Id; + telemetry: AgentTelemetry; + selectedDecision: TypedValue | null; + appliedControls: PortControl | null; +} + +export interface CommittedSnapshot { + descriptorRevision: U64; + publisherIncarnation: Id; + scope: Scope; + episodeId: Id; + sequence: U64; + worldTime: RationalNs; + agents: SnapshotAgent[]; + progress: TypedValue; + media: { views: ViewRef[]; audio: AudioRef[] }; + eventIds: Id[]; +} + +export function readSessionDescriptor(value: unknown): SessionDescriptor { + const reader = new Reader(value, 'SessionDescriptor'); + const descriptor: SessionDescriptor = { + sessionId: reader.id('sessionId'), + revision: reader.u64('revision'), + compositionDigest: reader.digest('compositionDigest'), + schedulerId: reader.constant('schedulerId', 'lockstep-v1'), + environment: readEnvironmentDescriptor(reader.value('environment')), + taskSchema: readSchemaRef(reader.value('taskSchema')), + agents: reader.list('agents', 1, MAX_AGENTS, (item) => { + const agent = new Reader(item, 'SessionDescriptor.agents'); + const entry: AgentDescriptor = { + agentId: agent.id('agentId'), + portId: agent.id('portId'), + profileDigest: agent.digest('profileDigest'), + datasetDigest: agent.digest('datasetDigest'), + indexDigest: agent.digest('indexDigest'), + neuronCount: agent.u64('neuronCount'), + rateRoles: agent.idList('rateRoles', 0, MAX_RATE_ROLES), + supportedStimuli: agent.idList('supportedStimuli', 0, MAX_SUPPORTED_STIMULI), + }; + agent.finish(); + requireUnique(entry.rateRoles, 'SessionDescriptor.agents rateRoles'); + requireUnique(entry.supportedStimuli, 'SessionDescriptor.agents supportedStimuli'); + return entry; + }), + assets: reader.list('assets', 0, MAX_ASSETS, readAssetRef), + }; + reader.finish(); + requireUnique( + descriptor.agents.map((agent) => agent.agentId), + 'SessionDescriptor.agents agentId', + ); + requireUnique( + descriptor.agents.map((agent) => agent.portId), + 'SessionDescriptor.agents portId', + ); + requireUnique( + descriptor.assets.map((asset) => asset.id), + 'SessionDescriptor.assets', + ); + for (const agent of descriptor.agents) { + if (!findPort(descriptor.environment, agent.portId)) { + fail( + `SessionDescriptor: agent "${agent.agentId}" is bound to port "${agent.portId}", which the environment does not declare`, + ); + } + } + return descriptor; +} + +export function readCommittedSnapshot(value: unknown): CommittedSnapshot { + const reader = new Reader(value, 'CommittedSnapshot'); + const descriptorRevision = reader.u64('descriptorRevision'); + const publisherIncarnation = reader.id('publisherIncarnation'); + const scope = readScope(reader.value('scope')); + const episodeId = reader.id('episodeId'); + const sequence = reader.u64('sequence'); + const worldTime = readRational(reader.value('worldTime')); + const agents = reader.list('agents', 1, MAX_AGENTS, (item) => { + const agent = new Reader(item, 'CommittedSnapshot.agents'); + const controls = agent.value('appliedControls'); + const entry: SnapshotAgent = { + agentId: agent.id('agentId'), + telemetry: readAgentTelemetry(agent.value('telemetry')), + selectedDecision: readNullableTypedValue(agent.value('selectedDecision')), + appliedControls: controls === null ? null : readPortControl(controls), + }; + agent.finish(); + return entry; + }); + const progress = readTypedValue(reader.value('progress')); + const mediaReader = new Reader(reader.value('media'), 'CommittedSnapshot.media'); + const media = { + views: readViewList(mediaReader, 'views'), + audio: readAudioList(mediaReader, 'audio'), + }; + mediaReader.finish(); + const eventIds = reader.idList('eventIds', 0, MAX_SNAPSHOT_EVENTS); + reader.finish(); + requireUnique( + agents.map((agent) => agent.agentId), + 'CommittedSnapshot.agents', + ); + requireUnique( + media.views.map((view) => view.viewId), + 'CommittedSnapshot.media.views', + ); + requireUnique(eventIds, 'CommittedSnapshot.eventIds'); + if (media.views.length > MAX_VIEWS) fail('CommittedSnapshot: at most 8 views'); + const atBoundaryZero = u64(scope.step) === 0n; + for (const agent of agents) { + // "Decisions/controls describe the transition ending at that boundary, null at initial + // boundary 0." (publishing-v1 section 3) + if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) { + fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null'); + } + if (!atBoundaryZero && (agent.selectedDecision === null || agent.appliedControls === null)) { + fail( + 'CommittedSnapshot: past boundary 0 every agent has a decision and applied controls', + ); + } + } + return { + descriptorRevision, + publisherIncarnation, + scope, + episodeId, + sequence, + worldTime, + agents, + progress, + media, + eventIds, + }; +} + +/** Descriptor agreement: revision, session, agent set and each agent's assigned port. */ +export function validateSnapshotAgainst( + snapshot: CommittedSnapshot, + descriptor: SessionDescriptor, +): void { + if (snapshot.descriptorRevision !== descriptor.revision) { + fail('CommittedSnapshot: descriptorRevision does not match the descriptor'); + } + if (snapshot.scope.sessionId !== descriptor.sessionId) { + fail('CommittedSnapshot: sessionId does not match the descriptor'); + } + for (const agent of snapshot.agents) { + const declared = descriptor.agents.find((candidate) => candidate.agentId === agent.agentId); + if (!declared) { + fail(`CommittedSnapshot: agent "${agent.agentId}" is not in the descriptor`); + } + validateTelemetryRoles(agent.telemetry, declared.rateRoles); + if (agent.appliedControls !== null) { + if (agent.appliedControls.portId !== declared.portId) { + fail( + `CommittedSnapshot: agent "${agent.agentId}" controls port "${agent.appliedControls.portId}", not its assigned "${declared.portId}"`, + ); + } + const port = findPort(descriptor.environment, declared.portId); + if (!port) fail('CommittedSnapshot: assigned port is not declared'); + validatePortControlAgainst(agent.appliedControls, port.controls); + } + } +} diff --git a/packages/session-types/src/reader.ts b/packages/session-types/src/reader.ts new file mode 100644 index 0000000..ce99004 --- /dev/null +++ b/packages/session-types/src/reader.ts @@ -0,0 +1,231 @@ +/** + * Reading one JSON object field by field, then refusing any field that was not read. + * + * The Rust crate's `flybus::wire::Fields` does the same job; keeping the two shaped alike is + * what lets the fixture corpus hold both languages to the same rules. + */ +import { ContractError, canonicalize, fail } from './canonical'; +import { + type Digest, + type Id, + type U64, + type ArtifactRef, + isDigest, + isId, + parseU64, + requireU64, +} from './scalar'; + +export class Reader { + private readonly map: Record; + private readonly seen = new Set(); + + constructor( + value: unknown, + private readonly what: string, + ) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${what} must be an object`); + } + this.map = value as Record; + } + + value(key: string): unknown { + this.seen.add(key); + if (!Object.prototype.hasOwnProperty.call(this.map, key)) { + fail(`${this.what}: missing field "${key}"`); + } + return this.map[key]; + } + + string(key: string): string { + const value = this.value(key); + if (typeof value !== 'string') fail(`${this.what}: ${key} must be a string`); + return value; + } + + id(key: string): Id { + const value = this.string(key); + if (!isId(value)) fail(`${this.what}: ${key} is not a valid id`); + return value; + } + + nullableId(key: string): Id | null { + const value = this.value(key); + if (value === null) return null; + return this.id(key); + } + + digest(key: string): Digest { + const value = this.string(key); + if (!isDigest(value)) fail(`${this.what}: ${key} must be 64 lowercase hex digits`); + return value; + } + + u64(key: string): U64 { + return requireU64(this.value(key), `${this.what}: ${key}`); + } + + int(key: string, low: number, high: number): number { + const value = this.value(key); + if (typeof value !== 'number' || !Number.isInteger(value) || value < low || value > high) { + fail(`${this.what}: ${key} must be an integer in ${low}..=${high}`); + } + return value; + } + + finite(key: string): number { + const value = this.value(key); + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${this.what}: ${key} must be a finite JSON number`); + } + if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) { + fail(`${this.what}: ${key} is outside the exact double range`); + } + return value; + } + + finiteIn(key: string, low: number, high: number): number { + const value = this.finite(key); + if (value < low || value > high) fail(`${this.what}: ${key} must be in [${low}, ${high}]`); + return value; + } + + boolean(key: string): boolean { + const value = this.value(key); + if (typeof value !== 'boolean') fail(`${this.what}: ${key} must be a boolean`); + return value; + } + + constantTrue(key: string): true { + if (!this.boolean(key)) fail(`${this.what}: ${key} must be true`); + return true; + } + + object(key: string): Record { + const value = this.value(key); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${this.what}: ${key} must be an object`); + } + return value as Record; + } + + array(key: string, low: number, high: number): unknown[] { + const value = this.value(key); + if (!Array.isArray(value) || value.length < low || value.length > high) { + fail(`${this.what}: ${key} must be an array of ${low}..=${high} items`); + } + return value; + } + + list(key: string, low: number, high: number, read: (item: unknown) => T): T[] { + return this.array(key, low, high).map((item, index) => { + try { + return read(item); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ContractError(`${this.what}: ${key}[${index}]: ${message}`); + } + }); + } + + idList(key: string, low: number, high: number): Id[] { + return this.list(key, low, high, (item) => { + if (!isId(item)) fail('every entry must be an id'); + return item; + }); + } + + enumeration(key: string, allowed: readonly T[]): T { + const value = this.string(key); + if (!(allowed as readonly string[]).includes(value)) { + fail(`${this.what}: ${key} must be one of ${allowed.join(', ')}`); + } + return value as T; + } + + constant(key: string, expected: T): T { + const value = this.string(key); + if (value !== expected) fail(`${this.what}: ${key} must be "${expected}"`); + return expected; + } + + boundedString(key: string, maxCodePoints: number): string { + const value = this.string(key); + if ([...value].length > maxCodePoints) { + fail(`${this.what}: ${key} must be at most ${maxCodePoints} code points`); + } + return value; + } + + nullableBoundedString(key: string, maxCodePoints: number): string | null { + return this.value(key) === null ? null : this.boundedString(key, maxCodePoints); + } + + /** Refuses fields that were not read. */ + finish(): void { + for (const key of Object.keys(this.map)) { + if (!this.seen.has(key)) fail(`${this.what}: unknown field "${key}"`); + } + } +} + +/** Fails on the first repeated key, naming it. */ +export function requireUnique(keys: readonly string[], what: string): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) fail(`${what}: duplicate "${key}"`); + seen.add(key); + } +} + +/** Fails unless `actual` is exactly `expected`, in that order. */ +export function requireSameOrder( + actual: readonly string[], + expected: readonly string[], + what: string, +): void { + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail( + `${what}: must list [${expected.join(', ')}] in that order, found [${actual.join(', ')}]`, + ); + } +} + +/** A bus `ArtifactRef`, read with the bus's own rules. */ +export function readArtifactRef(value: unknown): ArtifactRef { + const reader = new Reader(value, 'ArtifactRef'); + const storeId = reader.id('storeId'); + const artifactId = reader.id('artifactId'); + const generation = reader.u64('generation'); + const byteLength = reader.u64('byteLength'); + const contentType = reader.string('contentType'); + const digestValue = reader.value('digest'); + reader.finish(); + if (contentType.length < 1 || contentType.length > 127) { + fail('ArtifactRef: contentType must be 1..=127 printable ASCII characters'); + } + if (digestValue !== null && !isDigest(digestValue)) { + fail('ArtifactRef: digest must be null or 64 lowercase hex digits'); + } + return { + storeId, + artifactId, + generation, + byteLength, + contentType, + digest: digestValue as ArtifactRef['digest'], + }; +} + +/** The canonical JSON byte length of a value. */ +export function canonicalLength(value: unknown): number { + return canonicalize(value).length; +} + +/** `parseU64` that throws, for places that have already validated the string. */ +export function u64(value: U64): bigint { + const parsed = parseU64(value); + if (parsed === undefined) fail(`${value} is not a canonical U64 string`); + return parsed; +} diff --git a/packages/session-types/src/rpc.ts b/packages/session-types/src/rpc.ts new file mode 100644 index 0000000..8ee568a --- /dev/null +++ b/packages/session-types/src/rpc.ts @@ -0,0 +1,140 @@ +/** The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7. */ +import { fail, rejectBusIdentities } from './canonical'; +import { bodyDigest, readNullableScope } from './common'; +import { Reader } from './reader'; +import { type DomainRequestId, type Id, type Scope, domainRequestId } from './scalar'; +import { MAX_MESSAGE_CODE_POINTS } from './workers'; + +export const ERROR_CODES = [ + 'INVALID_ARGUMENT', + 'UNSUPPORTED', + 'IDENTITY_MISMATCH', + 'STALE_EPOCH', + 'STALE_STEP', + 'FUTURE_STEP', + 'INVALID_PHASE', + 'CONFLICT', + 'IN_PROGRESS', + 'BUSY', + 'BUFFER_INVALID', + 'RESULT_EXPIRED', + 'INCOMPATIBLE_STATE', + 'BACKEND_FAILURE', + 'INTERNAL', +] as const; +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export const MUTATION_CERTAINTIES = ['none', 'applied', 'unknown'] as const; +export type MutationCertainty = (typeof MUTATION_CERTAINTIES)[number]; + +/** The codes raised strictly before any mutation, so their certainty is `none`. */ +export const BEFORE_MUTATION: readonly ErrorCode[] = [ + 'INVALID_ARGUMENT', + 'UNSUPPORTED', + 'IDENTITY_MISMATCH', + 'STALE_EPOCH', + 'STALE_STEP', + 'FUTURE_STEP', + 'INVALID_PHASE', + 'CONFLICT', + 'IN_PROGRESS', + 'BUSY', + 'BUFFER_INVALID', + 'INCOMPATIBLE_STATE', +]; + +export interface SessionRpcRequest { + requestId: DomainRequestId; + scope: Scope | null; + params: Record; +} + +export interface SessionRpcSuccess { + type: 'result'; + requestId: DomainRequestId; + workerId: Id; + incarnationId: Id; + scope: Scope | null; + result: Record; +} + +export interface SessionRpcFailure { + type: 'error'; + requestId: DomainRequestId; + workerId: Id; + incarnationId: Id; + scope: Scope | null; + error: { code: ErrorCode; message: string; mutation: MutationCertainty }; +} + +export type SessionRpcOutcome = SessionRpcSuccess | SessionRpcFailure; + +export function readSessionRpcRequest(value: unknown): SessionRpcRequest { + const reader = new Reader(value, 'SessionRpcRequest'); + const request: SessionRpcRequest = { + requestId: domainRequestId(reader.string('requestId')), + scope: readNullableScope(reader.value('scope')), + params: reader.object('params'), + }; + reader.finish(); + rejectBusIdentities(request.params); + return request; +} + +/** The canonical body digest of this request under `method` (ipc-v1 section 5). */ +export function requestBodyDigest(request: SessionRpcRequest, method: string): string { + return bodyDigest(method, request.scope, request.params); +} + +export function readSessionRpcSuccess(value: unknown): SessionRpcSuccess { + const reader = new Reader(value, 'SessionRpcSuccess'); + const success: SessionRpcSuccess = { + type: reader.constant('type', 'result'), + requestId: domainRequestId(reader.string('requestId')), + workerId: reader.id('workerId'), + incarnationId: reader.id('incarnationId'), + scope: readNullableScope(reader.value('scope')), + result: reader.object('result'), + }; + reader.finish(); + return success; +} + +export function readSessionRpcFailure(value: unknown): SessionRpcFailure { + const reader = new Reader(value, 'SessionRpcFailure'); + const type = reader.constant('type', 'error'); + const requestId = domainRequestId(reader.string('requestId')); + const workerId = reader.id('workerId'); + const incarnationId = reader.id('incarnationId'); + const scope = readNullableScope(reader.value('scope')); + const errorReader = new Reader(reader.value('error'), 'SessionRpcFailure.error'); + const error = { + code: errorReader.enumeration('code', ERROR_CODES), + message: errorReader.boundedString('message', MAX_MESSAGE_CODE_POINTS), + mutation: errorReader.enumeration('mutation', MUTATION_CERTAINTIES), + }; + errorReader.finish(); + reader.finish(); + if (BEFORE_MUTATION.includes(error.code) && error.mutation !== 'none') { + fail(`SessionRpcFailure: ${error.code} is raised before mutation, so mutation is "none"`); + } + return { type, requestId, workerId, incarnationId, scope, error }; +} + +export function readSessionRpcOutcome(value: unknown): SessionRpcOutcome { + const type = (value as { type?: unknown } | null)?.type; + if (type === 'result') return readSessionRpcSuccess(value); + if (type === 'error') return readSessionRpcFailure(value); + return fail('SessionRpcOutcome: type must be "result" or "error"'); +} + +/** Replies echo the original scope (ipc-v1 section 3). */ +export function echoes(outcome: SessionRpcOutcome, request: SessionRpcRequest): boolean { + const sameScope = + outcome.scope === null || request.scope === null + ? outcome.scope === request.scope + : outcome.scope.sessionId === request.scope.sessionId && + outcome.scope.epoch === request.scope.epoch && + outcome.scope.step === request.scope.step; + return outcome.requestId === request.requestId && sameScope; +} diff --git a/packages/session-types/src/scalar.ts b/packages/session-types/src/scalar.ts new file mode 100644 index 0000000..8f1866e --- /dev/null +++ b/packages/session-types/src/scalar.ts @@ -0,0 +1,262 @@ +/** + * The domain scalars of ipc-v1 section 2, and the four identities that must never be confused. + * + * `Id`, `U64` and `Digest` are the bus encodings (bus-v1 section 3 defers to ipc-v1 for them), + * and `tests/encodings.test.ts` pins the same edge cases the Rust crate pins. + */ +import { fail } from './canonical'; + +/** `^[a-z0-9][a-z0-9._-]{0,63}$`. */ +export type Id = string; +/** `"0"` or `[1-9][0-9]*`, at most 18446744073709551615. A counter, never a JSON number. */ +export type U64 = string; +/** 64 lowercase hexadecimal digits (SHA-256). */ +export type Digest = string; + +const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const U64_TEXT = /^(0|[1-9][0-9]*)$/; +const DIGEST = /^[0-9a-f]{64}$/; +/** The largest U64, as a bigint. */ +export const U64_MAX = 18446744073709551615n; + +export function isId(value: unknown): value is Id { + return typeof value === 'string' && ID.test(value); +} + +export function isDigest(value: unknown): value is Digest { + return typeof value === 'string' && DIGEST.test(value); +} + +/** The value a `U64` string denotes, or `undefined` if it is not canonical. */ +export function parseU64(value: unknown): bigint | undefined { + if (typeof value !== 'string' || !U64_TEXT.test(value)) return undefined; + const parsed = BigInt(value); + return parsed <= U64_MAX ? parsed : undefined; +} + +export function requireU64(value: unknown, what: string): U64 { + if (parseU64(value) === undefined) fail(`${what} is not a canonical U64 string`); + return value as U64; +} + +/** RPC method: 1..=128 printable ASCII characters (bus-v1 section 5). */ +export function isMethod(value: unknown): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 128 && + [...value].every((character) => { + const point = character.codePointAt(0) ?? 0; + return point >= 0x20 && point <= 0x7e; + }) + ); +} + +// --------------------------------------------------------------------------------------------- +// The four identities + +declare const brand: unique symbol; +/** A branded string: assignable only through its own parser. */ +type Branded = string & { readonly [brand]: Name }; + +/** A bus RPC correlation id, `call-`. Not a domain operation id. */ +export type BusCallId = Branded<'BusCallId'>; +/** A domain operation id, `req-`. A safe retry keeps it and gets a new `BusCallId`. */ +export type DomainRequestId = Branded<'DomainRequestId'>; +/** A delivery (`dlv-`) or explicit-hold (`own-`) owner token. Connection-private. */ +export type OwnerToken = Branded<'OwnerToken'>; + +export type OwnerKind = 'delivery' | 'hold'; + +function serial(prefix: string, value: unknown): bigint | undefined { + if (typeof value !== 'string' || !value.startsWith(`${prefix}-`)) return undefined; + return parseU64(value.slice(prefix.length + 1)); +} + +export function isBusCallId(value: unknown): value is BusCallId { + return serial('call', value) !== undefined; +} + +export function isDomainRequestId(value: unknown): value is DomainRequestId { + return serial('req', value) !== undefined; +} + +export function ownerTokenKind(value: unknown): OwnerKind | undefined { + if (serial('dlv', value) !== undefined) return 'delivery'; + if (serial('own', value) !== undefined) return 'hold'; + return undefined; +} + +export function busCallId(value: unknown): BusCallId { + if (!isBusCallId(value)) fail('a bus callId must be canonical call-'); + return value; +} + +export function domainRequestId(value: unknown): DomainRequestId { + if (!isDomainRequestId(value)) fail('a domain requestId must be canonical req-'); + return value; +} + +export function ownerToken(value: unknown): OwnerToken { + if (ownerTokenKind(value) === undefined) { + fail('an owner token must be canonical dlv- or own-'); + } + return value as OwnerToken; +} + +/** The naming half of a bus `ArtifactRef`: what identifies the bytes. */ +export interface ArtifactIdentity { + storeId: Id; + artifactId: Id; + generation: U64; +} + +/** A transient bus artifact reference (bus-v1 section 4). Never an `AssetRef`. */ +export interface ArtifactRef { + storeId: Id; + artifactId: Id; + generation: U64; + byteLength: U64; + contentType: string; + digest: Digest | null; +} + +export function artifactIdentity(reference: ArtifactRef): ArtifactIdentity { + return { + storeId: reference.storeId, + artifactId: reference.artifactId, + generation: reference.generation, + }; +} + +// --------------------------------------------------------------------------------------------- +// RationalNs + +/** A nanosecond rational: reduced, positive denominator, zero encoded `0/1`. */ +export interface RationalNs { + numerator: U64; + denominator: U64; +} + +export const RATIONAL_ZERO: RationalNs = { numerator: '0', denominator: '1' }; + +function gcd(a: bigint, b: bigint): bigint { + let left = a; + let right = b; + while (right !== 0n) { + const rest = left % right; + left = right; + right = rest; + } + return left; +} + +function parts(value: RationalNs, what: string): [bigint, bigint] { + const numerator = parseU64(value.numerator); + const denominator = parseU64(value.denominator); + if (numerator === undefined || denominator === undefined) { + fail(`${what}: numerator and denominator are U64 strings`); + } + return [numerator, denominator]; +} + +/** The canonical-form rules: positive denominator, `0/1` zero, reduced fraction. */ +export function validateRational(value: RationalNs, what = 'RationalNs'): void { + const [numerator, denominator] = parts(value, what); + if (denominator === 0n) fail(`${what}: denominator must be positive`); + if (numerator === 0n && denominator !== 1n) fail(`${what}: zero is encoded 0/1`); + if (numerator !== 0n && gcd(numerator, denominator) !== 1n) { + fail(`${what}: fraction must be reduced`); + } +} + +/** Reduces, then validates: the constructor for arithmetic results. */ +export function reduced(numerator: bigint, denominator: bigint): RationalNs { + if (denominator <= 0n) fail('RationalNs: denominator must be positive'); + let n = numerator; + let d = denominator; + if (n === 0n) { + d = 1n; + } else { + const divisor = gcd(n, d); + n /= divisor; + d /= divisor; + } + if (n > U64_MAX || d > U64_MAX) fail('RationalNs: reduced value does not fit U64'); + return { numerator: n.toString(), denominator: d.toString() }; +} + +export function isRationalZero(value: RationalNs): boolean { + return parseU64(value.numerator) === 0n; +} + +export function requirePositiveRational(value: RationalNs, what: string): void { + if (isRationalZero(value)) fail(`${what}: duration must be positive`); +} + +export function addRational(left: RationalNs, right: RationalNs): RationalNs { + const [ln, ld] = parts(left, 'RationalNs'); + const [rn, rd] = parts(right, 'RationalNs'); + return reduced(ln * rd + rn * ld, ld * rd); +} + +export function subtractRational(left: RationalNs, right: RationalNs): RationalNs { + const [ln, ld] = parts(left, 'RationalNs'); + const [rn, rd] = parts(right, 'RationalNs'); + const a = ln * rd; + const b = rn * ld; + if (b > a) fail('RationalNs: subtraction would be negative'); + return reduced(a - b, ld * rd); +} + +export function multiplyRational(value: RationalNs, factor: bigint): RationalNs { + const [numerator, denominator] = parts(value, 'RationalNs'); + return reduced(numerator * factor, denominator); +} + +export function compareRational(left: RationalNs, right: RationalNs): -1 | 0 | 1 { + const [ln, ld] = parts(left, 'RationalNs'); + const [rn, rd] = parts(right, 'RationalNs'); + const a = ln * rd; + const b = rn * ld; + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * The step-v1 section 5 accumulator: `ticks = floor(value / tick)` and the remainder + * `value - ticks * tick`, which is always `>= 0` and `< tick`. + */ +export function divideFloor(value: RationalNs, tick: RationalNs): { ticks: U64; remainder: RationalNs } { + requirePositiveRational(tick, 'RationalNs divideFloor tick'); + const [vn, vd] = parts(value, 'RationalNs'); + const [tn, td] = parts(tick, 'RationalNs'); + const ticks = (vn * td) / (vd * tn); + if (ticks > U64_MAX) fail('RationalNs: tick count does not fit U64'); + const remainder = subtractRational(value, multiplyRational(tick, ticks)); + return { ticks: ticks.toString(), remainder }; +} + +// --------------------------------------------------------------------------------------------- +// Scope, SchemaRef, TypedValue + +/** The simulation timeline identity. Never a bus route or store incarnation. */ +export interface Scope { + sessionId: Id; + epoch: Id; + step: U64; +} + +export interface SchemaRef { + id: Id; + version: number; + digest: Digest; +} + +/** A schema identity plus an object, capped at 32 KiB of canonical JSON. */ +export interface TypedValue { + schema: SchemaRef; + value: Record; +} + +/** The canonical-JSON size limit of one `TypedValue`. */ +export const MAX_TYPED_VALUE_BYTES = 32 * 1024; diff --git a/packages/session-types/src/seed.ts b/packages/session-types/src/seed.ts new file mode 100644 index 0000000..b264552 --- /dev/null +++ b/packages/session-types/src/seed.ts @@ -0,0 +1,57 @@ +/** + * `seed-derivation-v1`: independent per-agent seeds from one recorded master seed. + * + * The specification is `docs/design/session-framework/seed-derivation-v1.md`, and + * `fixtures/seed-vectors.json` its test vectors, which the Rust crate reproduces. + */ +import { createHash } from 'node:crypto'; + +import { fail } from './canonical'; +import { requireUnique } from './reader'; +import { isId, parseU64 } from './scalar'; + +export const ALGORITHM = 'seed-derivation-v1'; +export const PREFIX = 'flybrain/seed-derivation-v1'; + +/** The exact bytes hashed: prefix, master seed and agent id, each followed by one newline. */ +export function material(masterSeed: bigint, agentId: string): Uint8Array { + if (!isId(agentId)) fail('seed derivation: agentId is not a valid id'); + if (masterSeed < 0n || masterSeed > 18446744073709551615n) { + fail('seed derivation: the master seed is a U64'); + } + return new TextEncoder().encode(`${PREFIX}\n${masterSeed.toString()}\n${agentId}\n`); +} + +export function materialDigest(masterSeed: bigint, agentId: string): string { + return createHash('sha256').update(material(masterSeed, agentId)).digest('hex'); +} + +/** + * The signed 32-bit seed `Agent.Initialize` takes for `agentId`: the first nonzero big-endian + * `u32` lane of the digest, as a two's-complement `i32`. + */ +export function agentSeed(masterSeed: bigint, agentId: string): number { + let bytes = Buffer.from(material(masterSeed, agentId)); + for (let round = 0; round < 4; round += 1) { + if (round > 0) bytes = Buffer.concat([bytes, Buffer.from(`${round}\n`, 'utf8')]); + const digest = createHash('sha256').update(bytes).digest(); + for (let offset = 0; offset < digest.length; offset += 4) { + const word = digest.readUInt32BE(offset); + if (word !== 0) return word | 0; + } + } + return fail('seed derivation: every lane of four digests was zero'); +} + +/** The seeds of a whole composition, in the order the agent ids are given. */ +export function compositionSeeds(masterSeed: bigint, agentIds: readonly string[]): number[] { + requireUnique(agentIds, 'seed derivation: agentIds'); + return agentIds.map((agentId) => agentSeed(masterSeed, agentId)); +} + +/** Parses a master seed from its `U64` decimal string. */ +export function masterSeed(text: string): bigint { + const parsed = parseU64(text); + if (parsed === undefined) fail('seed derivation: the master seed is a canonical U64 string'); + return parsed; +} diff --git a/packages/session-types/src/trace.ts b/packages/session-types/src/trace.ts new file mode 100644 index 0000000..339e31b --- /dev/null +++ b/packages/session-types/src/trace.ts @@ -0,0 +1,268 @@ +/** + * The trace format of step-v1 section 8, split into behaviour and operational metadata. + * + * `behaviourEquals` compares the first half only, which is the comparison section 8 asks for: + * sequential, concurrent and reversed runs must agree "excluding wall time, request ids and + * other explicitly operational metadata". + */ +import { canonicalize, digestOf, fail } from './canonical'; +import { readRational, readScope } from './common'; +import { MAX_VIEWS } from './media'; +import { Reader, requireUnique, u64 } from './reader'; +import { + type BusCallId, + type Digest, + type DomainRequestId, + type Id, + type OwnerToken, + type RationalNs, + type Scope, + type U64, + busCallId, + domainRequestId, + ownerToken, +} from './scalar'; +import { MAX_AGENTS, MAX_RATE_ROLES } from './workers'; + +export interface TraceAgent { + agentId: Id; + profileDigest: Digest; + ticksAdvanced: U64; + brainTicks: U64; + remainder: RationalNs; + decisionDigest: Digest; + committedStep: U64; +} + +export interface TraceObservation { + viewId: Id; + producedStep: U64; +} + +export interface TraceBehaviour { + scope: Scope; + agents: TraceAgent[]; + batchId: Id; + controlDigest: Digest; + acknowledgedBoundary: U64; + observationBoundaries: TraceObservation[]; + outcomeIds: Id[]; + eventIds: Id[]; + publishedBoundary: U64; +} + +export interface TraceRequest { + agentId: Id; + requestId: DomainRequestId; +} + +export interface TraceOperational { + wallTimeNs: U64; + prepareRequestIds: TraceRequest[]; + advanceRequestId: DomainRequestId; + commitRequestIds: TraceRequest[]; + busCallIds: BusCallId[]; + deliveryIds: OwnerToken[]; +} + +export interface TransitionTrace { + behaviour: TraceBehaviour; + operational: TraceOperational; +} + +export function readTraceBehaviour(value: unknown): TraceBehaviour { + const reader = new Reader(value, 'TraceBehaviour'); + const scope = readScope(reader.value('scope')); + const agents = reader.list('agents', 1, MAX_AGENTS, (item) => { + const agent = new Reader(item, 'TraceBehaviour.agents'); + const entry: TraceAgent = { + agentId: agent.id('agentId'), + profileDigest: agent.digest('profileDigest'), + ticksAdvanced: agent.u64('ticksAdvanced'), + brainTicks: agent.u64('brainTicks'), + remainder: readRational(agent.value('remainder')), + decisionDigest: agent.digest('decisionDigest'), + committedStep: agent.u64('committedStep'), + }; + agent.finish(); + return entry; + }); + const batchId = reader.id('batchId'); + const controlDigest = reader.digest('controlDigest'); + const acknowledgedBoundary = reader.u64('acknowledgedBoundary'); + const observationBoundaries = reader.list( + 'observationBoundaries', + 0, + MAX_VIEWS * 2, + (item) => { + const observation = new Reader(item, 'TraceBehaviour.observationBoundaries'); + const entry: TraceObservation = { + viewId: observation.id('viewId'), + producedStep: observation.u64('producedStep'), + }; + observation.finish(); + return entry; + }, + ); + const outcomeIds = reader.idList('outcomeIds', 0, MAX_RATE_ROLES); + const eventIds = reader.idList('eventIds', 0, MAX_RATE_ROLES); + const publishedBoundary = reader.u64('publishedBoundary'); + reader.finish(); + + requireUnique( + agents.map((agent) => agent.agentId), + 'TraceBehaviour.agents', + ); + requireUnique( + observationBoundaries.map((observation) => observation.viewId), + 'TraceBehaviour.observationBoundaries', + ); + requireUnique(eventIds, 'TraceBehaviour.eventIds'); + requireUnique(outcomeIds, 'TraceBehaviour.outcomeIds'); + const next = u64(scope.step) + 1n; + for (const agent of agents) { + if (u64(agent.committedStep) !== next) { + fail("TraceBehaviour: every commit acknowledgment is the transition's next boundary"); + } + } + if (u64(acknowledgedBoundary) !== next) { + fail('TraceBehaviour: the acknowledged boundary is scope.step + 1'); + } + if (u64(publishedBoundary) !== u64(acknowledgedBoundary)) { + fail('TraceBehaviour: the published boundary is the boundary every agent committed'); + } + return { + scope, + agents, + batchId, + controlDigest, + acknowledgedBoundary, + observationBoundaries, + outcomeIds, + eventIds, + publishedBoundary, + }; +} + +export function readTraceOperational(value: unknown): TraceOperational { + const reader = new Reader(value, 'TraceOperational'); + const readRequests = (item: unknown): TraceRequest => { + const request = new Reader(item, 'TraceOperational request'); + const entry: TraceRequest = { + agentId: request.id('agentId'), + requestId: domainRequestId(request.string('requestId')), + }; + request.finish(); + return entry; + }; + const operational: TraceOperational = { + wallTimeNs: reader.u64('wallTimeNs'), + prepareRequestIds: reader.list('prepareRequestIds', 1, MAX_AGENTS, readRequests), + advanceRequestId: domainRequestId(reader.string('advanceRequestId')), + commitRequestIds: reader.list('commitRequestIds', 1, MAX_AGENTS, readRequests), + busCallIds: reader.list('busCallIds', 0, 64, busCallId), + deliveryIds: reader.list('deliveryIds', 0, 64, ownerToken), + }; + reader.finish(); + requireUnique( + operational.prepareRequestIds.map((request) => request.agentId), + 'TraceOperational.prepareRequestIds', + ); + requireUnique( + operational.commitRequestIds.map((request) => request.agentId), + 'TraceOperational.commitRequestIds', + ); + requireUnique(operational.busCallIds, 'TraceOperational.busCallIds'); + requireUnique(operational.deliveryIds, 'TraceOperational.deliveryIds'); + return operational; +} + +export function readTransitionTrace(value: unknown): TransitionTrace { + const reader = new Reader(value, 'TransitionTrace'); + const trace: TransitionTrace = { + behaviour: readTraceBehaviour(reader.value('behaviour')), + operational: readTraceOperational(reader.value('operational')), + }; + reader.finish(); + const agents = trace.behaviour.agents.map((agent) => agent.agentId); + for (const phase of [trace.operational.prepareRequestIds, trace.operational.commitRequestIds]) { + for (const request of phase) { + if (!agents.includes(request.agentId)) { + fail( + `TransitionTrace: request recorded for "${request.agentId}", which is not in the transition`, + ); + } + } + } + return trace; +} + +/** Sorts the order-free collections, so completion order cannot change the comparison. */ +export function normalizeBehaviour(behaviour: TraceBehaviour): TraceBehaviour { + return { + ...behaviour, + agents: [...behaviour.agents].sort((left, right) => + left.agentId < right.agentId ? -1 : left.agentId > right.agentId ? 1 : 0, + ), + observationBoundaries: [...behaviour.observationBoundaries].sort((left, right) => + left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0, + ), + }; +} + +export function behaviourDigest(behaviour: TraceBehaviour): string { + return digestOf(normalizeBehaviour(behaviour)); +} + +export function behaviourEquals(left: TransitionTrace, right: TransitionTrace): boolean { + return ( + canonicalize(normalizeBehaviour(left.behaviour)) === + canonicalize(normalizeBehaviour(right.behaviour)) + ); +} + +/** The behaviour fields that differ, named. Empty when `behaviourEquals` holds. */ +export function behaviourDiff(left: TransitionTrace, right: TransitionTrace): string[] { + const a = normalizeBehaviour(left.behaviour); + const b = normalizeBehaviour(right.behaviour); + const out: string[] = []; + const differs = (first: unknown, second: unknown): boolean => + canonicalize(first) !== canonicalize(second); + if (differs(a.scope, b.scope)) out.push(`scope: ${canonicalize(a.scope)} vs ${canonicalize(b.scope)}`); + if (a.batchId !== b.batchId) out.push(`batchId: ${a.batchId} vs ${b.batchId}`); + if (a.controlDigest !== b.controlDigest) out.push('controlDigest differs'); + if (a.acknowledgedBoundary !== b.acknowledgedBoundary) { + out.push(`acknowledgedBoundary: ${a.acknowledgedBoundary} vs ${b.acknowledgedBoundary}`); + } + if (a.publishedBoundary !== b.publishedBoundary) { + out.push(`publishedBoundary: ${a.publishedBoundary} vs ${b.publishedBoundary}`); + } + if (differs(a.observationBoundaries, b.observationBoundaries)) { + out.push('observationBoundaries differ'); + } + if (differs(a.outcomeIds, b.outcomeIds)) out.push('outcomeIds differ'); + if (differs(a.eventIds, b.eventIds)) out.push('eventIds differ'); + const idsA = a.agents.map((agent) => agent.agentId); + const idsB = b.agents.map((agent) => agent.agentId); + if (differs(idsA, idsB)) { + out.push(`agents: [${idsA.join(', ')}] vs [${idsB.join(', ')}]`); + } else { + a.agents.forEach((agent, index) => { + if (differs(agent, b.agents[index])) { + out.push(`agent ${agent.agentId}: behaviour differs`); + } + }); + } + return out; +} + +/** Two whole runs agree on behaviour, transition by transition. */ +export function runsEqual( + left: readonly TransitionTrace[], + right: readonly TransitionTrace[], +): boolean { + return ( + left.length === right.length && + left.every((trace, index) => behaviourEquals(trace, right[index] as TransitionTrace)) + ); +} diff --git a/packages/session-types/src/workers.ts b/packages/session-types/src/workers.ts new file mode 100644 index 0000000..1b1de22 --- /dev/null +++ b/packages/session-types/src/workers.ts @@ -0,0 +1,996 @@ +/** The closed enums and method payloads of workers-v1, plus the `Worker.*` common methods. */ +import { fail } from './canonical'; +import { readRational, readScope } from './common'; +import { + type AudioDescriptor, + type AudioRef, + MAX_VIEWS, + type ViewDescriptor, + type ViewRef, + readAudioDescriptor, + readAudioList, + readViewDescriptor, + readViewList, + validateAudioAgainst, + validateViewAgainst, +} from './media'; +import { Reader, requireSameOrder, requireUnique, u64 } from './reader'; +import { + type Digest, + type DomainRequestId, + type Id, + type RationalNs, + type SchemaRef, + type Scope, + type TypedValue, + type U64, + addRational, + compareRational, + domainRequestId, + isRationalZero, + requirePositiveRational, +} from './scalar'; +import { readSchemaRef, readTypedValue, readNullableTypedValue } from './common'; + +export const MAX_AGENTS = 4; +export const MAX_PORTS = 4; +export const MAX_RATE_ROLES = 64; +export const MAX_STIMULI = 64; +export const MAX_REWARDS = 64; +export const MAX_BUTTONS = 32; +export const MAX_AXES = 16; +export const MAX_ACKNOWLEDGE = 16; +export const MAX_ENGINE_FRAME_LEN = 64; +/** Not stated by a document; this crate's choice, published in the schema set. */ +export const MAX_CAPABILITIES = 32; +export const MAX_SUPPORTED_MAJORS = 8; +export const MAX_MESSAGE_CODE_POINTS = 512; + +export const ROLES = ['agent', 'environment', 'coordinator'] as const; +export type Role = (typeof ROLES)[number]; + +export const WORKER_STATES = [ + 'uninitialized', + 'ready', + 'preparing', + 'prepared', + 'advancing', + 'committing', + 'capturing', + 'staged-restore', + 'restoring', + 'failed', + 'stopping', +] as const; +export type WorkerState = (typeof WORKER_STATES)[number]; + +export const RECOVERY = ['exact-checkpoint', 'episode-restart'] as const; +export type Recovery = (typeof RECOVERY)[number]; + +export const DETERMINISM = ['fixed-build', 'unverified'] as const; +export type Determinism = (typeof DETERMINISM)[number]; + +export const AXIS_RANGES = ['bipolar', 'unit'] as const; +export type AxisRange = (typeof AXIS_RANGES)[number]; + +export function axisBounds(range: AxisRange): [number, number] { + return range === 'bipolar' ? [-1, 1] : [0, 1]; +} + +export interface AssetRef { + id: Id; + digest: Digest; + byteLength: U64; + format: Id; +} + +export interface SensoryInput { + boundary: U64; + views: ViewRef[]; + structured: TypedValue | null; +} + +export interface Stimulus { + id: Id; + kindId: Id; + durationMs: number; +} + +export interface Reward { + eventId: Id; + ruleId: Id; + value: number; +} + +export interface AgentTelemetry { + brainTicks: U64; + populationRateHz: number; + rates: { roleId: Id; hz: number }[]; + learning: { enabled: boolean; updates: U64; changed: U64; signal: number }; +} + +export function readAssetRef(value: unknown): AssetRef { + const reader = new Reader(value, 'AssetRef'); + const asset: AssetRef = { + id: reader.id('id'), + digest: reader.digest('digest'), + byteLength: reader.u64('byteLength'), + format: reader.id('format'), + }; + reader.finish(); + if (u64(asset.byteLength) === 0n) fail('AssetRef: byteLength must be positive'); + return asset; +} + +export function readSensoryInput(value: unknown): SensoryInput { + const reader = new Reader(value, 'SensoryInput'); + const input: SensoryInput = { + boundary: reader.u64('boundary'), + views: readViewList(reader, 'views'), + structured: readNullableTypedValue(reader.value('structured')), + }; + reader.finish(); + for (const view of input.views) { + if (u64(view.producedStep) > u64(input.boundary)) { + fail(`SensoryInput: view "${view.viewId}" was produced after the observed boundary`); + } + } + return input; +} + +/** Required sensory views against the descriptors that declared them. */ +export function validateSensoryInputAgainst( + input: SensoryInput, + descriptors: readonly ViewDescriptor[], +): void { + for (const view of input.views) { + const descriptor = descriptors.find((candidate) => candidate.viewId === view.viewId); + if (!descriptor) { + fail(`SensoryInput: view "${view.viewId}" is not declared by the environment`); + } + validateViewAgainst(view, descriptor, u64(input.boundary)); + } +} + +/** A pixel-only profile rejects non-null structured input (workers-v1 section 1). */ +export function validateSensoryInputForProfile( + input: SensoryInput, + structuredSensing: boolean, +): void { + if (input.structured !== null && !structuredSensing) { + fail('SensoryInput: a pixel-only profile rejects non-null structured input'); + } +} + +export function readStimulus(value: unknown): Stimulus { + const reader = new Reader(value, 'Stimulus'); + const stimulus: Stimulus = { + id: reader.id('id'), + kindId: reader.id('kindId'), + durationMs: reader.finite('durationMs'), + }; + reader.finish(); + if (stimulus.durationMs <= 0) fail('Stimulus: durationMs must be finite and > 0'); + return stimulus; +} + +export function readReward(value: unknown): Reward { + const reader = new Reader(value, 'Reward'); + const reward: Reward = { + eventId: reader.id('eventId'), + ruleId: reader.id('ruleId'), + value: reader.finite('value'), + }; + reader.finish(); + return reward; +} + +export function readStimulusList(reader: Reader, key: string): Stimulus[] { + const items = reader.list(key, 0, MAX_STIMULI, readStimulus); + requireUnique( + items.map((item) => item.id), + key, + ); + return items; +} + +export function readRewardList(reader: Reader, key: string): Reward[] { + const items = reader.list(key, 0, MAX_REWARDS, readReward); + requireUnique( + items.map((item) => item.eventId), + key, + ); + return items; +} + +export function readAgentTelemetry(value: unknown): AgentTelemetry { + const reader = new Reader(value, 'AgentTelemetry'); + const brainTicks = reader.u64('brainTicks'); + const populationRateHz = reader.finiteIn('populationRateHz', 0, Number.MAX_VALUE); + const rates = reader.list(('rates'), 0, MAX_RATE_ROLES, (item) => { + const rate = new Reader(item, 'AgentTelemetry.rates'); + const entry = { roleId: rate.id('roleId'), hz: rate.finiteIn('hz', 0, Number.MAX_VALUE) }; + rate.finish(); + return entry; + }); + const learningReader = new Reader(reader.value('learning'), 'AgentTelemetry.learning'); + const learning = { + enabled: learningReader.boolean('enabled'), + updates: learningReader.u64('updates'), + changed: learningReader.u64('changed'), + signal: learningReader.finite('signal'), + }; + learningReader.finish(); + reader.finish(); + requireUnique( + rates.map((rate) => rate.roleId), + 'AgentTelemetry.rates', + ); + if (u64(learning.changed) > u64(learning.updates)) { + fail('AgentTelemetry: learning.changed cannot exceed learning.updates'); + } + return { brainTicks, populationRateHz, rates, learning }; +} + +/** Rates are in profile-defined order (workers-v1 section 1). */ +export function validateTelemetryRoles( + telemetry: AgentTelemetry, + roleOrder: readonly string[], +): void { + requireSameOrder( + telemetry.rates.map((rate) => rate.roleId), + roleOrder, + 'AgentTelemetry.rates', + ); +} + +// ------------------------------------------------------------------------------ agent methods + +export interface AgentInitializeParams { + agentId: Id; + profile: AssetRef; + seed: number; + initialInput: SensoryInput; + initialDecisionContext: TypedValue; + workerThreads: number; +} + +export interface AgentInitializeResult { + agentId: Id; + profileDigest: Digest; + tickDuration: RationalNs; + warmupTicks: U64; + committedStep: U64; + decisionContextDigest: Digest; + telemetry: AgentTelemetry; +} + +export interface PrepareParams { + agentId: Id; + profileDigest: Digest; + interval: RationalNs; + decisionContextDigest: Digest; + preStepStimulations: Stimulus[]; +} + +export interface PreparedDecision { + agentId: Id; + ticksAdvanced: U64; + brainTicks: U64; + remainder: RationalNs; + decision: TypedValue; +} + +export interface CommitParams { + agentId: Id; + preparedRequestId: DomainRequestId; + nextInput: SensoryInput; + nextDecisionContext: TypedValue; + rewards: Reward[]; + taskStimulations: Stimulus[]; +} + +export interface AgentCommitResult { + agentId: Id; + committedStep: U64; + decisionContextDigest: Digest; + telemetry: AgentTelemetry; +} + +export function readAgentInitializeParams(value: unknown): AgentInitializeParams { + const reader = new Reader(value, 'AgentInitializeParams'); + const params: AgentInitializeParams = { + agentId: reader.id('agentId'), + profile: readAssetRef(reader.value('profile')), + seed: reader.int('seed', -2_147_483_648, 2_147_483_647), + initialInput: readSensoryInput(reader.value('initialInput')), + initialDecisionContext: readTypedValue(reader.value('initialDecisionContext')), + workerThreads: reader.int('workerThreads', 1, 4_096), + }; + reader.finish(); + return params; +} + +export function readAgentInitializeResult(value: unknown): AgentInitializeResult { + const reader = new Reader(value, 'AgentInitializeResult'); + const result: AgentInitializeResult = { + agentId: reader.id('agentId'), + profileDigest: reader.digest('profileDigest'), + tickDuration: readRational(reader.value('tickDuration')), + warmupTicks: reader.u64('warmupTicks'), + committedStep: reader.u64('committedStep'), + decisionContextDigest: reader.digest('decisionContextDigest'), + telemetry: readAgentTelemetry(reader.value('telemetry')), + }; + reader.finish(); + requirePositiveRational(result.tickDuration, 'AgentInitializeResult.tickDuration'); + if (u64(result.committedStep) !== 0n) { + fail('AgentInitializeResult: committedStep must be "0"'); + } + return result; +} + +export function readPrepareParams(value: unknown): PrepareParams { + const reader = new Reader(value, 'PrepareParams'); + const params: PrepareParams = { + agentId: reader.id('agentId'), + profileDigest: reader.digest('profileDigest'), + interval: readRational(reader.value('interval')), + decisionContextDigest: reader.digest('decisionContextDigest'), + preStepStimulations: readStimulusList(reader, 'preStepStimulations'), + }; + reader.finish(); + requirePositiveRational(params.interval, 'PrepareParams.interval'); + return params; +} + +export function readPreparedDecision(value: unknown): PreparedDecision { + const reader = new Reader(value, 'PreparedDecision'); + const decision: PreparedDecision = { + agentId: reader.id('agentId'), + ticksAdvanced: reader.u64('ticksAdvanced'), + brainTicks: reader.u64('brainTicks'), + remainder: readRational(reader.value('remainder')), + decision: readTypedValue(reader.value('decision')), + }; + reader.finish(); + if (u64(decision.ticksAdvanced) > u64(decision.brainTicks)) { + fail('PreparedDecision: ticksAdvanced cannot exceed the total brainTicks'); + } + return decision; +} + +/** The remainder is always `>= 0` and `< one model tick` (step-v1 section 5). */ +export function validateRemainder(decision: PreparedDecision, tickDuration: RationalNs): void { + requirePositiveRational(tickDuration, 'tickDuration'); + if (compareRational(decision.remainder, tickDuration) >= 0) { + fail('PreparedDecision: remainder must be less than one model tick'); + } +} + +export function readCommitParams(value: unknown): CommitParams { + const reader = new Reader(value, 'CommitParams'); + const params: CommitParams = { + agentId: reader.id('agentId'), + preparedRequestId: domainRequestId(reader.string('preparedRequestId')), + nextInput: readSensoryInput(reader.value('nextInput')), + nextDecisionContext: readTypedValue(reader.value('nextDecisionContext')), + rewards: readRewardList(reader, 'rewards'), + taskStimulations: readStimulusList(reader, 'taskStimulations'), + }; + reader.finish(); + return params; +} + +/** The commit of transition `k -> k+1` carries `scope.step = k` and the input for `k+1`. */ +export function validateCommitAgainstScope(params: CommitParams, scope: Scope): void { + const expected = u64(scope.step) + 1n; + if (u64(params.nextInput.boundary) !== expected) { + fail(`CommitParams: nextInput.boundary must be ${expected} for scope.step ${scope.step}`); + } +} + +export function readAgentCommitResult(value: unknown): AgentCommitResult { + const reader = new Reader(value, 'AgentCommitResult'); + const result: AgentCommitResult = { + agentId: reader.id('agentId'), + committedStep: reader.u64('committedStep'), + decisionContextDigest: reader.digest('decisionContextDigest'), + telemetry: readAgentTelemetry(reader.value('telemetry')), + }; + reader.finish(); + return result; +} + +// ------------------------------------------------------------------- environment methods + +export interface AxisSchema { + id: Id; + range: AxisRange; + neutral: number; +} + +export interface ControllerSchema { + schema: SchemaRef; + buttons: Id[]; + axes: AxisSchema[]; +} + +export interface PortControl { + portId: Id; + buttons: { id: Id; down: boolean }[]; + axes: { id: Id; value: number }[]; +} + +export interface PortDescriptor { + portId: Id; + controls: ControllerSchema; +} + +export interface EnvironmentDescriptor { + backendDigest: Digest; + contentDigest: Digest; + configurationDigest: Digest; + stepDuration: RationalNs; + ports: PortDescriptor[]; + inspectionSchema: SchemaRef; + views: ViewDescriptor[]; + audio: AudioDescriptor[]; + recovery: Recovery; + determinism: Determinism; +} + +export interface WorldObservation { + boundary: U64; + worldTime: RationalNs; + engineFrame: string | null; + sensoryViews: ViewRef[]; + inspection: TypedValue; + broadcastViews: ViewRef[]; + audio: AudioRef[]; +} + +export interface EnvironmentInitializeParams { + backendConfig: AssetRef; + taskConfig: AssetRef; + episodeId: Id; + portBindings: { portId: Id; agentId: Id }[]; +} + +export interface EnvironmentInitializeResult { + descriptor: EnvironmentDescriptor; + observation: WorldObservation; +} + +export interface AdvanceParams { + batchId: Id; + controls: PortControl[]; +} + +export interface StepResult { + batchId: Id; + appliedFromStep: U64; + nextStep: U64; + appliedControlsDigest: Digest; + observation: WorldObservation; +} + +export function readControllerSchema(value: unknown): ControllerSchema { + const reader = new Reader(value, 'ControllerSchema'); + const controls: ControllerSchema = { + schema: readSchemaRef(reader.value('schema')), + buttons: reader.idList('buttons', 0, MAX_BUTTONS), + axes: reader.list('axes', 0, MAX_AXES, (item) => { + const axis = new Reader(item, 'ControllerSchema.axes'); + const id = axis.id('id'); + const range = axis.enumeration('range', AXIS_RANGES); + const [low, high] = axisBounds(range); + const neutral = axis.finiteIn('neutral', low, high); + axis.finish(); + return { id, range, neutral }; + }), + }; + reader.finish(); + requireUnique(controls.buttons, 'ControllerSchema.buttons'); + requireUnique( + controls.axes.map((axis) => axis.id), + 'ControllerSchema.axes', + ); + return controls; +} + +export function readPortControl(value: unknown): PortControl { + const reader = new Reader(value, 'PortControl'); + const control: PortControl = { + portId: reader.id('portId'), + buttons: reader.list('buttons', 0, MAX_BUTTONS, (item) => { + const button = new Reader(item, 'PortControl.buttons'); + const entry = { id: button.id('id'), down: button.boolean('down') }; + button.finish(); + return entry; + }), + axes: reader.list('axes', 0, MAX_AXES, (item) => { + const axis = new Reader(item, 'PortControl.axes'); + const entry = { id: axis.id('id'), value: axis.finite('value') }; + axis.finish(); + return entry; + }), + }; + reader.finish(); + requireUnique( + control.buttons.map((button) => button.id), + 'PortControl.buttons', + ); + requireUnique( + control.axes.map((axis) => axis.id), + 'PortControl.axes', + ); + return control; +} + +/** + * Every declared button and axis, in descriptor order, in range. An out-of-range value is + * refused, never clamped (workers-v1 section 3). + */ +export function validatePortControlAgainst( + control: PortControl, + controls: ControllerSchema, +): void { + requireSameOrder( + control.buttons.map((button) => button.id), + controls.buttons, + 'PortControl.buttons', + ); + requireSameOrder( + control.axes.map((axis) => axis.id), + controls.axes.map((axis) => axis.id), + 'PortControl.axes', + ); + control.axes.forEach((axis, index) => { + const schema = controls.axes[index] as AxisSchema; + const [low, high] = axisBounds(schema.range); + if (axis.value < low || axis.value > high) { + fail( + `PortControl: axis "${axis.id}" value ${axis.value} is outside its ${schema.range} range and is refused, not clamped`, + ); + } + }); +} + +export function readEnvironmentDescriptor(value: unknown): EnvironmentDescriptor { + const reader = new Reader(value, 'EnvironmentDescriptor'); + const descriptor: EnvironmentDescriptor = { + backendDigest: reader.digest('backendDigest'), + contentDigest: reader.digest('contentDigest'), + configurationDigest: reader.digest('configurationDigest'), + stepDuration: readRational(reader.value('stepDuration')), + ports: reader.list('ports', 1, MAX_PORTS, (item) => { + const port = new Reader(item, 'EnvironmentDescriptor.ports'); + const entry = { + portId: port.id('portId'), + controls: readControllerSchema(port.value('controls')), + }; + port.finish(); + return entry; + }), + inspectionSchema: readSchemaRef(reader.value('inspectionSchema')), + views: reader.list('views', 0, MAX_VIEWS, readViewDescriptor), + audio: reader.list('audio', 0, 8, readAudioDescriptor), + recovery: reader.enumeration('recovery', RECOVERY), + determinism: reader.enumeration('determinism', DETERMINISM), + }; + reader.finish(); + requirePositiveRational(descriptor.stepDuration, 'EnvironmentDescriptor.stepDuration'); + requireUnique( + descriptor.ports.map((port) => port.portId), + 'EnvironmentDescriptor.ports', + ); + requireUnique( + descriptor.views.map((view) => view.viewId), + 'EnvironmentDescriptor.views', + ); + requireUnique( + descriptor.audio.map((stream) => stream.streamId), + 'EnvironmentDescriptor.audio', + ); + return descriptor; +} + +export function findPort( + descriptor: EnvironmentDescriptor, + portId: string, +): PortDescriptor | undefined { + return descriptor.ports.find((port) => port.portId === portId); +} + +/** One complete batch: every configured port once, in descriptor order. */ +export function validateBatch( + descriptor: EnvironmentDescriptor, + controls: readonly PortControl[], +): void { + requireSameOrder( + controls.map((control) => control.portId), + descriptor.ports.map((port) => port.portId), + 'Environment.Advance controls', + ); + controls.forEach((control, index) => { + validatePortControlAgainst(control, (descriptor.ports[index] as PortDescriptor).controls); + }); +} + +export function readWorldObservation(value: unknown): WorldObservation { + const reader = new Reader(value, 'WorldObservation'); + const observation: WorldObservation = { + boundary: reader.u64('boundary'), + worldTime: readRational(reader.value('worldTime')), + engineFrame: reader.nullableBoundedString('engineFrame', MAX_ENGINE_FRAME_LEN), + sensoryViews: readViewList(reader, 'sensoryViews'), + inspection: readTypedValue(reader.value('inspection')), + broadcastViews: readViewList(reader, 'broadcastViews'), + audio: readAudioList(reader, 'audio'), + }; + reader.finish(); + for (const view of [...observation.sensoryViews, ...observation.broadcastViews]) { + if (u64(view.producedStep) > u64(observation.boundary)) { + fail('WorldObservation: a view cannot be produced after the boundary'); + } + } + return observation; +} + +export function validateObservationAgainst( + observation: WorldObservation, + descriptor: EnvironmentDescriptor, +): void { + const expected = descriptor.inspectionSchema; + const found = observation.inspection.schema; + if ( + found.id !== expected.id || + found.version !== expected.version || + found.digest !== expected.digest + ) { + fail("WorldObservation: inspection must use the descriptor's inspectionSchema"); + } + for (const view of [...observation.sensoryViews, ...observation.broadcastViews]) { + const declared = descriptor.views.find((candidate) => candidate.viewId === view.viewId); + if (!declared) { + fail(`WorldObservation: view "${view.viewId}" is not declared by the descriptor`); + } + validateViewAgainst(view, declared, u64(observation.boundary)); + } + for (const chunk of observation.audio) { + const declared = descriptor.audio.find((candidate) => candidate.streamId === chunk.streamId); + if (!declared) { + fail(`WorldObservation: audio stream "${chunk.streamId}" is not declared by the descriptor`); + } + validateAudioAgainst(chunk, declared); + } +} + +export function readEnvironmentInitializeParams(value: unknown): EnvironmentInitializeParams { + const reader = new Reader(value, 'EnvironmentInitializeParams'); + const params: EnvironmentInitializeParams = { + backendConfig: readAssetRef(reader.value('backendConfig')), + taskConfig: readAssetRef(reader.value('taskConfig')), + episodeId: reader.id('episodeId'), + portBindings: reader.list('portBindings', 1, MAX_PORTS, (item) => { + const binding = new Reader(item, 'EnvironmentInitializeParams.portBindings'); + const entry = { portId: binding.id('portId'), agentId: binding.id('agentId') }; + binding.finish(); + return entry; + }), + }; + reader.finish(); + requireUnique( + params.portBindings.map((binding) => binding.portId), + 'EnvironmentInitializeParams.portBindings portId', + ); + requireUnique( + params.portBindings.map((binding) => binding.agentId), + 'EnvironmentInitializeParams.portBindings agentId', + ); + return params; +} + +export function readEnvironmentInitializeResult(value: unknown): EnvironmentInitializeResult { + const reader = new Reader(value, 'EnvironmentInitializeResult'); + const result: EnvironmentInitializeResult = { + descriptor: readEnvironmentDescriptor(reader.value('descriptor')), + observation: readWorldObservation(reader.value('observation')), + }; + reader.finish(); + if (u64(result.observation.boundary) !== 0n) { + fail('EnvironmentInitializeResult: the initial observation is boundary 0'); + } + if (!isRationalZero(result.observation.worldTime)) { + fail('EnvironmentInitializeResult: initial worldTime is zero (0/1)'); + } + validateObservationAgainst(result.observation, result.descriptor); + return result; +} + +export function readAdvanceParams(value: unknown): AdvanceParams { + const reader = new Reader(value, 'AdvanceParams'); + const params: AdvanceParams = { + batchId: reader.id('batchId'), + controls: reader.list('controls', 1, MAX_PORTS, readPortControl), + }; + reader.finish(); + requireUnique( + params.controls.map((control) => control.portId), + 'AdvanceParams.controls', + ); + return params; +} + +export function readStepResult(value: unknown): StepResult { + const reader = new Reader(value, 'StepResult'); + const result: StepResult = { + batchId: reader.id('batchId'), + appliedFromStep: reader.u64('appliedFromStep'), + nextStep: reader.u64('nextStep'), + appliedControlsDigest: reader.digest('appliedControlsDigest'), + observation: readWorldObservation(reader.value('observation')), + }; + reader.finish(); + if (u64(result.nextStep) !== u64(result.appliedFromStep) + 1n) { + fail('StepResult: nextStep must be appliedFromStep + 1; one result is one step'); + } + if (u64(result.observation.boundary) !== u64(result.nextStep)) { + fail('StepResult: the observation boundary must be nextStep'); + } + return result; +} + +/** Exactly boundary `k+1`, with world time advanced by exactly one `stepDuration`. */ +export function validateStepResultAgainst( + result: StepResult, + descriptor: EnvironmentDescriptor, + previous: WorldObservation, +): void { + validateObservationAgainst(result.observation, descriptor); + const expected = addRational(previous.worldTime, descriptor.stepDuration); + if (compareRational(result.observation.worldTime, expected) !== 0) { + fail('StepResult: worldTime must advance by exactly one stepDuration'); + } + if (u64(result.observation.boundary) !== u64(previous.boundary) + 1n) { + fail('StepResult: the observation must be exactly the next boundary'); + } +} + +// --------------------------------------------------------------------- common worker methods + +export interface HelloParams { + sessionId: Id; + expectedWorkerId: Id; + role: Role; + supportedMajors: number[]; +} + +export interface HelloResult { + selectedMajor: 1; + selectedMinor: 0; + workerId: Id; + incarnationId: Id; + role: Role; + buildDigest: Digest; + contractDigest: Digest; + capabilities: Id[]; + limits: { maxAgents: number; maxPorts: number }; +} + +export interface StatusResult { + state: WorkerState; + currentScope: Scope | null; + activeRequestId: DomainRequestId | null; + lastCompletedRequestId: DomainRequestId | null; + lastBatchId: Id | null; + progressCounter: U64; +} + +export interface AcknowledgeParams { + requestIds: DomainRequestId[]; +} + +export interface AcknowledgeResult { + acknowledged: DomainRequestId[]; +} + +export interface ShutdownParams { + reason: Id; +} + +export interface ShutdownResult { + stopping: true; +} + +export interface TaskEvent { + id: Id; + kindId: Id; + sourceStep: U64; + agentId: Id | null; + payload: TypedValue; +} + +export interface EpisodeRequest { + kind: 'terminal'; + reason: Id; + outcome: TypedValue; +} + +/** Required capabilities are agent-step-v1 and world-step-v1 for their roles. */ +export function requiredCapability(role: Role): string | null { + if (role === 'agent') return 'agent-step-v1'; + if (role === 'environment') return 'world-step-v1'; + return null; +} + +export function readHelloParams(value: unknown): HelloParams { + const reader = new Reader(value, 'HelloParams'); + const params: HelloParams = { + sessionId: reader.id('sessionId'), + expectedWorkerId: reader.id('expectedWorkerId'), + role: reader.enumeration('role', ROLES), + supportedMajors: reader.list('supportedMajors', 1, MAX_SUPPORTED_MAJORS, (item) => { + if (typeof item !== 'number' || !Number.isInteger(item) || item < 1 || item > 65_535) { + fail('every supported major must be an integer 1..=65535'); + } + return item; + }), + }; + reader.finish(); + requireUnique( + params.supportedMajors.map(String), + 'HelloParams.supportedMajors', + ); + return params; +} + +export function readHelloResult(value: unknown): HelloResult { + const reader = new Reader(value, 'HelloResult'); + const selectedMajor = reader.int('selectedMajor', 1, 1) as 1; + const selectedMinor = reader.int('selectedMinor', 0, 0) as 0; + const workerId = reader.id('workerId'); + const incarnationId = reader.id('incarnationId'); + const role = reader.enumeration('role', ROLES); + const buildDigest = reader.digest('buildDigest'); + const contractDigest = reader.digest('contractDigest'); + const capabilities = reader.idList('capabilities', 0, MAX_CAPABILITIES); + const limitsReader = new Reader(reader.value('limits'), 'HelloResult.limits'); + const limits = { + maxAgents: limitsReader.int('maxAgents', 1, MAX_AGENTS), + maxPorts: limitsReader.int('maxPorts', 1, MAX_PORTS), + }; + limitsReader.finish(); + reader.finish(); + requireUnique(capabilities, 'HelloResult.capabilities'); + const required = requiredCapability(role); + if (required !== null && !capabilities.includes(required)) { + fail(`HelloResult: a ${role} worker must advertise ${required}`); + } + return { + selectedMajor, + selectedMinor, + workerId, + incarnationId, + role, + buildDigest, + contractDigest, + capabilities, + limits, + }; +} + +export function readStatusResult(value: unknown): StatusResult { + const reader = new Reader(value, 'StatusResult'); + const state = reader.enumeration('state', WORKER_STATES); + const currentScopeValue = reader.value('currentScope'); + const currentScope = currentScopeValue === null ? null : readScope(currentScopeValue); + const active = reader.value('activeRequestId'); + const completed = reader.value('lastCompletedRequestId'); + const result: StatusResult = { + state, + currentScope, + activeRequestId: active === null ? null : domainRequestId(active), + lastCompletedRequestId: completed === null ? null : domainRequestId(completed), + lastBatchId: reader.nullableId('lastBatchId'), + progressCounter: reader.u64('progressCounter'), + }; + reader.finish(); + if (result.state === 'uninitialized' && result.currentScope !== null) { + fail('StatusResult: an uninitialized worker has a null currentScope'); + } + return result; +} + +export function readAcknowledgeParams(value: unknown): AcknowledgeParams { + const reader = new Reader(value, 'AcknowledgeParams'); + const params: AcknowledgeParams = { + requestIds: reader.list('requestIds', 1, MAX_ACKNOWLEDGE, domainRequestId), + }; + reader.finish(); + requireUnique(params.requestIds, 'AcknowledgeParams.requestIds'); + return params; +} + +export function readAcknowledgeResult(value: unknown): AcknowledgeResult { + const reader = new Reader(value, 'AcknowledgeResult'); + const result: AcknowledgeResult = { + acknowledged: reader.list('acknowledged', 0, MAX_ACKNOWLEDGE, domainRequestId), + }; + reader.finish(); + requireUnique(result.acknowledged, 'AcknowledgeResult.acknowledged'); + return result; +} + +export function readShutdownParams(value: unknown): ShutdownParams { + const reader = new Reader(value, 'ShutdownParams'); + const params = { reason: reader.id('reason') }; + reader.finish(); + return params; +} + +export function readShutdownResult(value: unknown): ShutdownResult { + const reader = new Reader(value, 'ShutdownResult'); + const result: ShutdownResult = { stopping: reader.constantTrue('stopping') }; + reader.finish(); + return result; +} + +export function readTaskEvent(value: unknown): TaskEvent { + const reader = new Reader(value, 'TaskEvent'); + const event: TaskEvent = { + id: reader.id('id'), + kindId: reader.id('kindId'), + sourceStep: reader.u64('sourceStep'), + agentId: reader.nullableId('agentId'), + payload: readTypedValue(reader.value('payload')), + }; + reader.finish(); + return event; +} + +export function readEpisodeRequest(value: unknown): EpisodeRequest { + const reader = new Reader(value, 'EpisodeRequest'); + const request: EpisodeRequest = { + kind: reader.constant('kind', 'terminal'), + reason: reader.id('reason'), + outcome: readTypedValue(reader.value('outcome')), + }; + reader.finish(); + return request; +} + +export interface ActivateRestoreResult { + committedStep: U64; + checkpointId: Id; + observation: WorldObservation | null; +} + +export function readActivateRestoreResult(value: unknown): ActivateRestoreResult { + const reader = new Reader(value, 'ActivateRestoreResult'); + const observationValue = reader.value('observation'); + const result: ActivateRestoreResult = { + committedStep: reader.u64('committedStep'), + checkpointId: reader.id('checkpointId'), + observation: observationValue === null ? null : readWorldObservation(observationValue), + }; + reader.finish(); + if ( + result.observation !== null && + u64(result.observation.boundary) !== u64(result.committedStep) + ) { + fail('ActivateRestoreResult: the observation boundary must be the committed step'); + } + return result; +} + +/** An environment returns its restored observation; an agent returns null. */ +export function validateActivateRestoreForRole(result: ActivateRestoreResult, role: Role): void { + if (role === 'environment' && result.observation === null) { + fail('ActivateRestoreResult: an environment must return its restored observation'); + } + if (role === 'agent' && result.observation !== null) { + fail('ActivateRestoreResult: an agent returns a null observation'); + } +} diff --git a/packages/session-types/tests/canonical.test.ts b/packages/session-types/tests/canonical.test.ts new file mode 100644 index 0000000..41435bf --- /dev/null +++ b/packages/session-types/tests/canonical.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + MAX_ENVELOPE_BYTES, + canonicalize, + digestOf, + parseStrict, + requireEnvelopeFit, +} from '../src/canonical'; +import { bodyDigest, operationKeyDigest, readScope } from '../src/common'; +import * as fixtures from '../src/fixtures'; + +const ASTRAL = String.fromCodePoint(0x10400); +const FULLWIDTH_A = String.fromCodePoint(0xff21); +const E_ACUTE = String.fromCodePoint(0xe9); + +test('object keys are sorted by UTF-16 code unit', () => { + const value: Record = { b: 1, a: 2, A: 3 }; + value[E_ACUTE] = 4; + value[ASTRAL] = 5; + value[FULLWIDTH_A] = 6; + assert.equal( + canonicalize(value), + `{"A":3,"a":2,"b":1,"${E_ACUTE}":4,"${ASTRAL}":5,"${FULLWIDTH_A}":6}`, + 'an astral key, whose leading surrogate is D801, sorts before U+FF21', + ); +}); + +test('numbers print the way ECMAScript prints them', () => { + const file = fixtures.load('boundaries.json'); + for (const item of fixtures.section(file, 'doubles')) { + const record = item as Record; + const value = record.value as number; + if (record.accept === true) { + assert.equal(canonicalize(value), record.canonical, `${value} prints as its canonical form`); + } else { + assert.throws(() => canonicalize(value), `${value} must be refused`); + } + } +}); + +test('strings are escaped the way JSON.stringify escapes them', () => { + const bell = String.fromCharCode(7); + const del = String.fromCharCode(0x7f); + const value = { s: ['q"', 'b\\', 't\t', 'n\n', bell, del].join(' ') }; + assert.equal( + canonicalize(value), + JSON.stringify(value), + 'for a single-key object the two agree exactly, escape for escape', + ); + assert.ok(canonicalize(value).includes('\\u0007'), 'a control character uses lowercase \\u'); + assert.ok(canonicalize(value).includes(del), 'DEL is not an escape in JSON'); +}); + +test('canonical form does not depend on the input formatting', () => { + const compact = '{"b":[1,2,{"y":true,"x":null}],"a":"z"}'; + const pretty = '{\n "a" : "z",\n "b": [ 1, 2, { "x": null, "y": true } ]\n}'; + assert.equal(canonicalize(parseStrict(compact)), canonicalize(parseStrict(pretty))); + assert.equal(digestOf(parseStrict(compact)), digestOf(parseStrict(pretty))); +}); + +test('duplicate keys and invalid UTF-8 never parse', () => { + assert.throws(() => parseStrict('{"a":1,"a":2}'), /duplicate key/); + assert.throws(() => parseStrict('{"a":{"b":1,"b":2}}'), /duplicate key/); + assert.throws(() => parseStrict(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d])), /UTF-8/); + assert.throws(() => parseStrict('{"a":1} {"b":2}'), /trailing data/); + assert.throws(() => parseStrict('{"a":NaN}')); + assert.throws(() => parseStrict('{"a":Infinity}')); + assert.throws(() => parseStrict('{"a":1')); + assert.throws(() => parseStrict('')); +}); + +test('an envelope over 64 KiB is refused', () => { + assert.throws(() => requireEnvelopeFit({ pad: 'a'.repeat(MAX_ENVELOPE_BYTES) }, 0)); + const small = { pad: 'a' }; + const length = canonicalize(small).length; + assert.equal(requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length), MAX_ENVELOPE_BYTES); + assert.throws(() => requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length + 1)); +}); + +test('operation keys match the fixture and separate the operations they should', () => { + const file = fixtures.load('operations.json'); + const digests: [string, string][] = []; + for (const item of fixtures.section(file, 'keys')) { + const name = fixtures.field(item, 'name'); + const digest = operationKeyDigest({ + scope: readScope(fixtures.member(item, 'scope')), + method: fixtures.field(item, 'method'), + workerId: fixtures.field(item, 'workerId'), + }); + assert.equal(digest, fixtures.field(item, 'digest'), `${name}: operation key digest`); + digests.push([name, digest]); + } + digests.forEach(([name, digest], index) => { + for (const [otherName, other] of digests.slice(index + 1)) { + assert.notEqual(digest, other, `${name} and ${otherName} are different operations`); + } + }); +}); + +test('canonical bodies match the fixture', () => { + const file = fixtures.load('operations.json'); + for (const item of fixtures.section(file, 'bodies')) { + const scopeValue = fixtures.member(item, 'scope'); + const digest = bodyDigest( + fixtures.field(item, 'method'), + scopeValue === null ? null : readScope(scopeValue), + fixtures.member(item, 'params'), + ); + assert.equal(digest, fixtures.field(item, 'digest'), fixtures.field(item, 'name')); + } +}); + +test('operation pairs agree with the fixture about sameness', () => { + const file = fixtures.load('operations.json'); + for (const item of fixtures.section(file, 'pairs')) { + const name = fixtures.field(item, 'name'); + const reason = fixtures.field(item, 'reason'); + const worker = fixtures.field(item, 'workerId'); + const rightWorker = fixtures.optionalField(item, 'rightWorkerId') ?? worker; + const side = (key: string, workerId: string): [string, string] => { + const value = fixtures.member(item, key); + const method = fixtures.field(value, 'method'); + const scope = readScope(fixtures.member(value, 'scope')); + const params = fixtures.member(value, 'params'); + return [operationKeyDigest({ scope, method, workerId }), bodyDigest(method, scope, params)]; + }; + const [leftKey, leftBody] = side('left', worker); + const [rightKey, rightBody] = side('right', rightWorker); + const record = item as Record; + assert.equal(leftKey === rightKey, record.sameKey, `${name}: key sameness. ${reason}`); + assert.equal(leftBody === rightBody, record.sameBody, `${name}: body sameness. ${reason}`); + } +}); + +test('a domain body can never carry a bus identity', () => { + const file = fixtures.load('operations.json'); + for (const item of fixtures.section(file, 'rejected')) { + assert.throws( + () => + bodyDigest( + fixtures.field(item, 'method'), + readScope(fixtures.member(item, 'scope')), + fixtures.member(item, 'params'), + ), + `${fixtures.field(item, 'name')} must be refused`, + ); + } +}); diff --git a/packages/session-types/tests/checkpoint.test.ts b/packages/session-types/tests/checkpoint.test.ts new file mode 100644 index 0000000..57d38cb --- /dev/null +++ b/packages/session-types/tests/checkpoint.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { canonicalize, parseStrict } from '../src/canonical'; +import * as checkpoint from '../src/checkpoint'; +import * as fixtures from '../src/fixtures'; + +function envelopeBytes(): Uint8Array { + const file = fixtures.load('checkpoint-envelope.json'); + const envelope = fixtures.member(file, 'envelope'); + return fixtures.decodeBase64(fixtures.field(envelope, 'base64')); +} + +test('the fixture envelope decodes to its recorded layout', () => { + const file = fixtures.load('checkpoint-envelope.json'); + const bytes = envelopeBytes(); + const envelope = checkpoint.decode(bytes); + checkpoint.validateManifest(envelope); + + const layout = fixtures.member(fixtures.member(file, 'envelope'), 'layout') as Record< + string, + unknown + >; + assert.equal(Buffer.from(bytes.subarray(0, 8)).toString('ascii'), checkpoint.MAGIC); + assert.equal(String(bytes.length), layout.totalBytes); + assert.equal(String(envelope.layout.tableOffset), layout.tableOffset); + assert.equal(envelope.layout.manifestBytes, layout.manifestBytes); + const entries = layout.entries as Record[]; + assert.equal(envelope.layout.entries.length, entries.length); + envelope.layout.entries.forEach((entry, index) => { + const recorded = entries[index]!; + assert.equal(entry.name, recorded.name); + assert.equal(String(entry.offset), recorded.offset); + assert.equal(String(entry.byteLength), recorded.byteLength); + assert.equal(entry.digest, recorded.digest); + assert.equal(entry.offset % 8, 0, 'payloads start on an eight-byte boundary'); + }); + + for (const payload of fixtures.section(file, 'payloads')) { + const name = fixtures.field(payload, 'name'); + const expected = fixtures.decodeBase64(fixtures.field(payload, 'base64')); + const found = envelope.payloads.find((candidate) => candidate.name === name); + assert.ok(found, `payload ${name} must be present`); + assert.deepEqual(found.bytes, expected, `payload ${name} must come back byte for byte`); + } + assert.equal(canonicalize(envelope.manifest), canonicalize(fixtures.member(file, 'manifest'))); +}); + +test('every recorded corruption is refused', () => { + const file = fixtures.load('checkpoint-envelope.json'); + const bytes = envelopeBytes(); + for (const item of fixtures.section(file, 'corruption')) { + const name = fixtures.field(item, 'name'); + const offset = (item as Record).offset as number; + const corrupted = Uint8Array.from(bytes); + corrupted[offset] = (corrupted[offset]! ^ 0x01) & 0xff; + assert.throws(() => checkpoint.decode(corrupted), `${name} must be refused`); + } + assert.throws(() => checkpoint.decode(bytes.subarray(0, bytes.length - 1))); + assert.throws(() => checkpoint.decode(bytes.subarray(0, 8))); +}); + +test('a FLYSIM01 envelope is not read as a session checkpoint', () => { + const manifest = Buffer.from('{"schemaVersion":2,"chunks":["agent"]}', 'utf8'); + const length = Buffer.alloc(4); + length.writeUInt32LE(manifest.length, 0); + const chunkLength = Buffer.alloc(4); + chunkLength.writeUInt32LE(64, 0); + const legacy = Buffer.concat([ + Buffer.from('FLYSIM01', 'ascii'), + length, + manifest, + chunkLength, + Buffer.alloc(64), + Buffer.alloc(4), // the CRC32 footer + ]); + assert.throws(() => checkpoint.decode(legacy), /magic/); +}); + +test('the layout is deterministic and the manifest is canonical', () => { + const manifest = parseStrict('{"b":2,"a":1}'); + const payloads = [ + { name: 'one', bytes: new TextEncoder().encode('first') }, + { name: 'two', bytes: new Uint8Array(9) }, + ]; + const bytes = checkpoint.encode(manifest, payloads); + assert.deepEqual(bytes, checkpoint.encode(manifest, payloads)); + const envelope = checkpoint.decode(bytes); + const start = checkpoint.HEADER_BYTES; + const end = start + envelope.layout.manifestBytes; + assert.equal(Buffer.from(bytes.subarray(start, end)).toString('utf8'), '{"a":1,"b":2}'); + assert.throws(() => + checkpoint.encode(manifest, [ + { name: 'one', bytes: new Uint8Array() }, + { name: 'one', bytes: new Uint8Array() }, + ]), + ); + assert.throws(() => checkpoint.encode(manifest, [{ name: 'One', bytes: new Uint8Array() }])); +}); + +test('an envelope written here is read by the same rules the Rust crate wrote its fixture with', () => { + const file = fixtures.load('checkpoint-envelope.json'); + const manifest = fixtures.member(file, 'manifest'); + const payloads = fixtures + .section(file, 'payloads') + .map((payload) => ({ + name: fixtures.field(payload, 'name'), + bytes: fixtures.decodeBase64(fixtures.field(payload, 'base64')), + })); + assert.deepEqual( + Uint8Array.from(checkpoint.encode(manifest, payloads)), + Uint8Array.from(envelopeBytes()), + 'the two implementations produce the same bytes for the same inputs', + ); +}); + +test('a manifest missing a required field is not a complete checkpoint', () => { + const file = fixtures.load('checkpoint-envelope.json'); + const full = fixtures.member(file, 'manifest') as Record; + for (const field of checkpoint.REQUIRED_MANIFEST_FIELDS) { + const manifest = { ...full }; + delete manifest[field]; + const envelope = checkpoint.decode(checkpoint.encode(manifest, [])); + assert.throws(() => checkpoint.validateManifest(envelope), `without ${field}`); + } +}); diff --git a/packages/session-types/tests/descriptor-checks.test.ts b/packages/session-types/tests/descriptor-checks.test.ts new file mode 100644 index 0000000..6d5d93b --- /dev/null +++ b/packages/session-types/tests/descriptor-checks.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as fixtures from '../src/fixtures'; +import { readCommittedSnapshot, readSessionDescriptor, validateSnapshotAgainst } from '../src/publishing'; +import { + findPort, + readEnvironmentDescriptor, + readPortControl, + readSensoryInput, + readStepResult, + readWorldObservation, + validateBatch, + validateObservationAgainst, + validatePortControlAgainst, + validateSensoryInputAgainst, + validateStepResultAgainst, +} from '../src/workers'; +import { requiredProducedStep, frameBytes } from '../src/media'; + +test('every descriptor check lands the way the fixture says', () => { + const file = fixtures.load('descriptor-checks.json'); + const descriptor = readEnvironmentDescriptor(fixtures.member(file, 'descriptor')); + const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor')); + const session = readSessionDescriptor(fixtures.member(file, 'sessionDescriptor')); + const previous = readWorldObservation(fixtures.member(file, 'stepResultPrevious')); + + for (const item of fixtures.cases(file)) { + const name = fixtures.field(item, 'name'); + const kind = fixtures.field(item, 'kind'); + const reason = fixtures.optionalField(item, 'reason') ?? ''; + const expectAccept = fixtures.field(item, 'expect') === 'accept'; + const value = fixtures.member(item, 'value'); + const attempt = () => { + switch (kind) { + case 'portControl': { + const control = readPortControl(value); + const port = findPort(descriptor, control.portId); + if (!port) throw new Error('no such port'); + validatePortControlAgainst(control, port.controls); + return; + } + case 'advanceControls': { + const controls = (value as unknown[]).map(readPortControl); + validateBatch(descriptor, controls); + return; + } + case 'sensoryInput': + validateSensoryInputAgainst(readSensoryInput(value), descriptor.views); + return; + case 'sensoryInputDelayed': + validateSensoryInputAgainst(readSensoryInput(value), delayed.views); + return; + case 'worldObservation': + validateObservationAgainst(readWorldObservation(value), descriptor); + return; + case 'stepResult': + validateStepResultAgainst(readStepResult(value), descriptor, previous); + return; + case 'snapshot': + validateSnapshotAgainst(readCommittedSnapshot(value), session); + return; + default: + throw new Error(`unknown descriptor check kind "${kind}"`); + } + }; + if (expectAccept) { + assert.doesNotThrow(attempt, `${name} must be accepted. ${reason}`); + } else { + assert.throws(attempt, `${name} must be refused. ${reason}`); + } + } +}); + +test('the required producing boundary saturates at zero', () => { + const file = fixtures.load('descriptor-checks.json'); + const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor')); + const view = delayed.views[0]!; + assert.equal(view.observationDelaySteps, 2); + assert.equal(requiredProducedStep(view, 0n), 0n); + assert.equal(requiredProducedStep(view, 1n), 0n); + assert.equal(requiredProducedStep(view, 2n), 0n); + assert.equal(requiredProducedStep(view, 3n), 1n); + assert.equal(frameBytes(view), 160 * 4 * 144); +}); diff --git a/packages/session-types/tests/encodings.test.ts b/packages/session-types/tests/encodings.test.ts new file mode 100644 index 0000000..7ad9d77 --- /dev/null +++ b/packages/session-types/tests/encodings.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as fixtures from '../src/fixtures'; +import { readArtifactRef } from '../src/reader'; +import { + artifactIdentity, + isBusCallId, + isDigest, + isDomainRequestId, + isId, + ownerTokenKind, + parseU64, +} from '../src/scalar'; +import { readAssetRef } from '../src/workers'; + +test('U64 boundaries reject from the fixture', () => { + const file = fixtures.load('boundaries.json'); + for (const item of fixtures.section(file, 'u64')) { + const record = item as Record; + assert.equal( + parseU64(record.text) !== undefined, + record.accept, + `${String(record.text)}: ${String(record.reason)}`, + ); + } +}); + +test('the Id and Digest encodings are the ones the bus uses', () => { + for (const id of ['a', 'fly-a', '0', 'a.b_c-d', 'a'.repeat(64)]) { + assert.ok(isId(id), `${id} is an Id`); + } + for (const id of ['', 'A', '-a', '.a', 'a b', 'fly/a', 'a'.repeat(65)]) { + assert.ok(!isId(id), `${id} is not an Id`); + } + assert.ok(isDigest('a'.repeat(64))); + assert.ok(!isDigest('A'.repeat(64)), 'digests are lowercase'); + assert.ok(!isDigest('a'.repeat(63)), 'digests are 64 hex digits'); + assert.ok(!isDigest('g'.repeat(64)), 'digests are hexadecimal'); +}); + +test('the four identities never accept each other spellings', () => { + const file = fixtures.load('identities.json'); + for (const item of fixtures.cases(file)) { + const record = item as Record; + const text = record.text as string; + assert.equal(isBusCallId(text), record.busCallId, `busCallId ${text}`); + assert.equal(isDomainRequestId(text), record.domainRequestId, `domainRequestId ${text}`); + assert.equal(ownerTokenKind(text) ?? null, record.ownerToken, `owner token ${text}`); + const accepted = [ + isBusCallId(text), + isDomainRequestId(text), + ownerTokenKind(text) !== undefined, + ].filter(Boolean).length; + assert.ok(accepted <= 1, `${text} is accepted by more than one identity type`); + } +}); + +test('an artifact identity is the naming half of an ArtifactRef, and an asset is neither', () => { + const file = fixtures.load('identities.json'); + const artifact = fixtures.member(file, 'artifact'); + const reference = readArtifactRef(fixtures.member(artifact, 'ref')); + assert.deepEqual(artifactIdentity(reference), fixtures.member(artifact, 'identity')); + const asset = readAssetRef(fixtures.member(file, 'asset')); + assert.notEqual( + asset.id, + reference.artifactId, + 'the fixture asset and artifact are deliberately different things', + ); +}); diff --git a/packages/session-types/tests/payloads.test.ts b/packages/session-types/tests/payloads.test.ts new file mode 100644 index 0000000..f3f9158 --- /dev/null +++ b/packages/session-types/tests/payloads.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { canonicalize, parseStrict, requireEnvelopeFit, sha256Hex } from '../src/canonical'; +import { MAX_TYPED_VALUE_BYTES } from '../src/scalar'; +import { readTypedValue } from '../src/common'; +import * as fixtures from '../src/fixtures'; +import { READERS, roundTrip } from './readers'; + +test('every valid case round trips and canonicalizes to its recorded bytes', () => { + const file = fixtures.load('valid.json'); + const cases = fixtures.cases(file); + for (const item of cases) { + const name = fixtures.field(item, 'name'); + const typeName = fixtures.field(item, 'type'); + const value = fixtures.member(item, 'value'); + let written: unknown; + try { + written = roundTrip(typeName, value); + } catch (error) { + assert.fail(`${name} (${typeName}) must be accepted: ${String(error)}`); + } + const canonicalIn = canonicalize(value); + assert.equal( + canonicalize(written), + canonicalIn, + `${name}: reading and writing must preserve every field`, + ); + assert.equal(canonicalIn, fixtures.field(item, 'canonical'), `${name}: canonical JSON`); + assert.equal(sha256Hex(canonicalIn), fixtures.field(item, 'digest'), `${name}: digest`); + } + assert.ok(cases.length >= 70, 'the valid fixture should stay broad'); +}); + +test('every type this package reads appears in the valid fixture', () => { + const covered = fixtures + .cases(fixtures.load('valid.json')) + .map((item) => fixtures.field(item, 'type')); + const missing = Object.keys(READERS).filter((typeName) => !covered.includes(typeName)); + assert.deepEqual(missing, [], 'every readable type needs at least one accepted fixture'); +}); + +test('every invalid case is refused', () => { + const file = fixtures.load('invalid.json'); + const cases = fixtures.cases(file); + for (const item of cases) { + const name = fixtures.field(item, 'name'); + const typeName = fixtures.field(item, 'type'); + const reason = fixtures.field(item, 'reason'); + assert.throws( + () => roundTrip(typeName, fixtures.member(item, 'value')), + `${name} (${typeName}) must be refused: ${reason}`, + ); + } + assert.ok(cases.length >= 80, 'the invalid fixture should stay broad'); +}); + +test('every raw byte case is refused before or during validation', () => { + for (const item of fixtures.cases(fixtures.load('raw.json'))) { + const name = fixtures.field(item, 'name'); + const typeName = fixtures.field(item, 'type'); + const reason = fixtures.field(item, 'reason'); + const bytes = fixtures.decodeBase64(fixtures.field(item, 'base64')); + // parseStrict is the only door into the readers, so a byte sequence that does not parse + // never reaches validation. + assert.throws( + () => roundTrip(typeName, parseStrict(bytes)), + `${name} must be refused: ${reason}`, + ); + } +}); + +test('generated boundary cases land on the right side of every limit', () => { + const file = fixtures.load('generated.json'); + const padSchema = fixtures.member(file, 'padSchema'); + for (const item of fixtures.cases(file)) { + const name = fixtures.field(item, 'name'); + const kind = fixtures.field(item, 'kind'); + const expectAccept = fixtures.field(item, 'expect') === 'accept'; + const record = item as Record; + const attempt = () => { + switch (kind) { + case 'padded-typed-value': { + const pad = 'a'.repeat(record.padCharacters as number); + readTypedValue({ schema: padSchema, value: { pad } }); + return; + } + case 'padded-request': { + const pad = 'a'.repeat(record.padCharacters as number); + const total = record.envelopeTotal as number; + const body = roundTrip('SessionRpcRequest', { + requestId: 'req-1', + scope: null, + params: { pad }, + }); + requireEnvelopeFit(body, total - canonicalize(body).length); + return; + } + case 'error-message': + case 'error-message-astral': { + const character = kind === 'error-message' ? 'x' : '\u{10400}'; + const message = character.repeat(record.codePoints as number); + roundTrip('SessionRpcFailure', { + type: 'error', + requestId: 'req-41', + workerId: 'fly-a', + incarnationId: 'inc-1', + scope: null, + error: { code: 'INTERNAL', message, mutation: 'unknown' }, + }); + return; + } + default: + throw new Error(`unknown generated case kind "${kind}"`); + } + }; + if (expectAccept) { + assert.doesNotThrow(attempt, `${name} must be accepted`); + } else { + assert.throws(attempt, `${name} must be refused`); + } + } +}); + +test('a typed value at the cap is accepted and one byte more is not', () => { + const schema = { id: 'pad.v1', version: 1, digest: sha256Hex('pad.v1') }; + const overhead = canonicalize({ schema, value: { pad: '' } }).length; + const atCap = readTypedValue({ + schema, + value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead) }, + }); + assert.equal(canonicalize(atCap).length, MAX_TYPED_VALUE_BYTES); + assert.throws(() => + readTypedValue({ schema, value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead + 1) } }), + ); +}); diff --git a/packages/session-types/tests/rational.test.ts b/packages/session-types/tests/rational.test.ts new file mode 100644 index 0000000..b1cf461 --- /dev/null +++ b/packages/session-types/tests/rational.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { readRational } from '../src/common'; +import * as fixtures from '../src/fixtures'; +import { + RATIONAL_ZERO, + U64_MAX, + addRational, + compareRational, + divideFloor, + multiplyRational, + reduced, + requirePositiveRational, + subtractRational, + validateRational, +} from '../src/scalar'; + +test('the accumulator produces the fixture tick counts and remainders', () => { + const file = fixtures.load('rational.json'); + for (const item of fixtures.section(file, 'accumulator')) { + const name = fixtures.field(item, 'name'); + const step = readRational(fixtures.member(item, 'stepDuration')); + const tick = readRational(fixtures.member(item, 'tickDuration')); + let accumulator = RATIONAL_ZERO; + let total = 0n; + fixtures.section(item, 'steps').forEach((expected, index) => { + accumulator = addRational(accumulator, step); + const { ticks, remainder } = divideFloor(accumulator, tick); + accumulator = remainder; + total += BigInt(ticks); + assert.equal(ticks, fixtures.field(expected, 'ticks'), `${name}: ticks at step ${index}`); + assert.deepEqual( + remainder, + readRational(fixtures.member(expected, 'remainder')), + `${name}: remainder at step ${index}`, + ); + assert.ok(compareRational(remainder, tick) < 0, `${name}: remainder below one tick`); + }); + assert.equal(total.toString(), fixtures.field(item, 'totalTicks'), `${name}: total ticks`); + } +}); + +test('checked arithmetic reduces or refuses', () => { + const file = fixtures.load('rational.json'); + for (const item of fixtures.section(file, 'add')) { + const left = readRational(fixtures.member(item, 'a')); + const right = readRational(fixtures.member(item, 'b')); + const record = item as Record; + if (record.sum !== undefined) { + assert.deepEqual(addRational(left, right), readRational(record.sum as never)); + } else { + assert.throws(() => addRational(left, right)); + } + } + for (const item of fixtures.section(file, 'subtract')) { + const left = readRational(fixtures.member(item, 'a')); + const right = readRational(fixtures.member(item, 'b')); + const record = item as Record; + if (record.difference !== undefined) { + assert.deepEqual(subtractRational(left, right), readRational(record.difference as never)); + } else { + assert.throws(() => subtractRational(left, right)); + } + } + for (const item of fixtures.section(file, 'multiply')) { + const value = readRational(fixtures.member(item, 'a')); + const factor = BigInt(fixtures.field(item, 'k')); + const record = item as Record; + if (record.product !== undefined) { + assert.deepEqual(multiplyRational(value, factor), readRational(record.product as never)); + } else { + assert.throws(() => multiplyRational(value, factor)); + } + } + for (const item of fixtures.section(file, 'compare')) { + const left = readRational(fixtures.member(item, 'a')); + const right = readRational(fixtures.member(item, 'b')); + const expected = { less: -1, equal: 0, greater: 1 }[fixtures.field(item, 'ordering')]; + assert.equal(compareRational(left, right), expected); + } +}); + +test('zero has exactly one encoding and durations must be positive', () => { + validateRational(RATIONAL_ZERO); + assert.throws(() => validateRational({ numerator: '0', denominator: '2' }), /0\/1/); + assert.throws(() => validateRational({ numerator: '1', denominator: '0' }), /positive/); + assert.throws(() => validateRational({ numerator: '2', denominator: '4' }), /reduced/); + assert.throws(() => requirePositiveRational(RATIONAL_ZERO, 'worldTime')); + assert.throws(() => divideFloor(RATIONAL_ZERO, RATIONAL_ZERO), /positive/); +}); + +test('reduction refuses a result that does not fit U64', () => { + const big = { numerator: U64_MAX.toString(), denominator: '1' }; + assert.throws(() => multiplyRational(big, 2n), /does not fit U64/); + assert.throws(() => addRational(big, big), /does not fit U64/); + assert.deepEqual(reduced(U64_MAX * 2n, 2n), big); +}); diff --git a/packages/session-types/tests/readers.ts b/packages/session-types/tests/readers.ts new file mode 100644 index 0000000..e3dba75 --- /dev/null +++ b/packages/session-types/tests/readers.ts @@ -0,0 +1,122 @@ +/** One place that knows how to read every type named by a fixture. */ +import type { Json } from '../src/canonical'; +import { + readRational, + readSchemaRef, + readScope, + readTypedValue, +} from '../src/common'; +import { + readActivateRestoreParams, + readAudioDescriptor, + readAudioRef, + readCaptureParams, + readCaptureResult, + readStageRestoreParams, + readStageRestoreResult, + readViewDescriptor, + readViewRef, +} from '../src/media'; +import { readCommittedSnapshot, readSessionDescriptor } from '../src/publishing'; +import { + readSessionRpcFailure, + readSessionRpcRequest, + readSessionRpcSuccess, +} from '../src/rpc'; +import { + readTraceBehaviour, + readTraceOperational, + readTransitionTrace, +} from '../src/trace'; +import { + readAcknowledgeParams, + readAcknowledgeResult, + readActivateRestoreResult, + readAdvanceParams, + readAgentCommitResult, + readAgentInitializeParams, + readAgentInitializeResult, + readAgentTelemetry, + readAssetRef, + readCommitParams, + readControllerSchema, + readEnvironmentDescriptor, + readEnvironmentInitializeParams, + readEnvironmentInitializeResult, + readEpisodeRequest, + readHelloParams, + readHelloResult, + readPortControl, + readPrepareParams, + readPreparedDecision, + readReward, + readSensoryInput, + readShutdownParams, + readShutdownResult, + readStatusResult, + readStepResult, + readStimulus, + readTaskEvent, + readWorldObservation, +} from '../src/workers'; + +/** Every type the fixtures name, and the reader that validates it. */ +export const READERS: Record unknown> = { + Scope: readScope, + RationalNs: readRational, + SchemaRef: readSchemaRef, + TypedValue: readTypedValue, + SessionRpcRequest: readSessionRpcRequest, + SessionRpcSuccess: readSessionRpcSuccess, + SessionRpcFailure: readSessionRpcFailure, + AssetRef: readAssetRef, + SensoryInput: readSensoryInput, + Stimulus: readStimulus, + Reward: readReward, + AgentTelemetry: readAgentTelemetry, + AgentInitializeParams: readAgentInitializeParams, + AgentInitializeResult: readAgentInitializeResult, + PrepareParams: readPrepareParams, + PreparedDecision: readPreparedDecision, + CommitParams: readCommitParams, + AgentCommitResult: readAgentCommitResult, + ControllerSchema: readControllerSchema, + PortControl: readPortControl, + EnvironmentDescriptor: readEnvironmentDescriptor, + EnvironmentInitializeParams: readEnvironmentInitializeParams, + EnvironmentInitializeResult: readEnvironmentInitializeResult, + WorldObservation: readWorldObservation, + AdvanceParams: readAdvanceParams, + StepResult: readStepResult, + HelloParams: readHelloParams, + HelloResult: readHelloResult, + StatusResult: readStatusResult, + AcknowledgeParams: readAcknowledgeParams, + AcknowledgeResult: readAcknowledgeResult, + ShutdownParams: readShutdownParams, + ShutdownResult: readShutdownResult, + TaskEvent: readTaskEvent, + EpisodeRequest: readEpisodeRequest, + ViewDescriptor: readViewDescriptor, + ViewRef: readViewRef, + AudioDescriptor: readAudioDescriptor, + AudioRef: readAudioRef, + CaptureParams: readCaptureParams, + CaptureResult: readCaptureResult, + StageRestoreParams: readStageRestoreParams, + StageRestoreResult: readStageRestoreResult, + ActivateRestoreParams: readActivateRestoreParams, + ActivateRestoreResult: readActivateRestoreResult, + SessionDescriptor: readSessionDescriptor, + CommittedSnapshot: readCommittedSnapshot, + TraceBehaviour: readTraceBehaviour, + TraceOperational: readTraceOperational, + TransitionTrace: readTransitionTrace, +}; + +/** Reads the value as `typeName` and hands back what the reader reconstructed. */ +export function roundTrip(typeName: string, value: Json): unknown { + const reader = READERS[typeName]; + if (!reader) throw new Error(`no fixture reader for type "${typeName}"`); + return reader(value); +} diff --git a/packages/session-types/tests/schema.test.ts b/packages/session-types/tests/schema.test.ts new file mode 100644 index 0000000..4579056 --- /dev/null +++ b/packages/session-types/tests/schema.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { canonicalize, digestOf, parseStrict, sha256Hex } from '../src/canonical'; +import * as fixtures from '../src/fixtures'; + +/** + * The schema set is generated by the Rust crate; this side hashes the checked-in file with its + * own canonical JSON and digest, which is the cross-language half of `contractDigest`. + */ +test('the contract digest is the digest of the checked-in schema set', () => { + const bytes = fixtures.loadBytes('schema-set.json'); + const text = Buffer.from(bytes).toString('utf8'); + assert.ok(text.endsWith('\n'), 'the file is the canonical set plus one newline'); + const canonical = text.slice(0, -1); + const recorded = fixtures.load('contract-digest.json') as Record; + assert.equal(sha256Hex(canonical), recorded.contractDigest); + assert.equal(canonical.length, recorded.schemaSetBytes); + // ... and the file really is canonical JSON: reserializing it changes nothing. + assert.equal(canonicalize(parseStrict(canonical)), canonical); + assert.equal(digestOf(parseStrict(canonical)), recorded.contractDigest); +}); + +test('the contract digest survives reformatting and changes when a schema changes', () => { + const set = parseStrict( + Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1), + ) as Record; + const recorded = fixtures.load('contract-digest.json') as Record; + const pretty = parseStrict(JSON.stringify(set, null, 4)); + assert.equal(digestOf(pretty), recorded.contractDigest, 'pretty printing is not a change'); + + const types = set.types as Record[]; + const renamed = structuredClone(set); + ((renamed.types as Record[])[0]!.fields as Record[])[0]!.name = + 'sessionIdentifier'; + assert.notEqual(digestOf(renamed), recorded.contractDigest, 'a renamed field is a change'); + + const widened = structuredClone(set); + for (const limit of widened.limits as Record[]) { + if (limit.name === 'maxAgents') limit.value = 8; + } + assert.notEqual(digestOf(widened), recorded.contractDigest, 'a widened bound is a change'); + + const dropped = structuredClone(set); + (dropped.types as unknown[]).pop(); + assert.notEqual(digestOf(dropped), recorded.contractDigest, 'a dropped type is a change'); + assert.ok(types.length >= 50, 'the schema set should stay broad'); +}); + +test('the schema set publishes the limits this package enforces', async () => { + const set = parseStrict( + Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1), + ) as Record; + const limits = new Map( + (set.limits as Record[]).map((limit) => [ + limit.name as string, + limit.value as number, + ]), + ); + const workers = await import('../src/workers'); + const media = await import('../src/media'); + const scalar = await import('../src/scalar'); + const canonical = await import('../src/canonical'); + assert.equal(limits.get('maxAgents'), workers.MAX_AGENTS); + assert.equal(limits.get('maxPorts'), workers.MAX_PORTS); + assert.equal(limits.get('maxRateRoles'), workers.MAX_RATE_ROLES); + assert.equal(limits.get('maxStimuliPerOperation'), workers.MAX_STIMULI); + assert.equal(limits.get('maxRewardsPerOperation'), workers.MAX_REWARDS); + assert.equal(limits.get('maxButtons'), workers.MAX_BUTTONS); + assert.equal(limits.get('maxAxes'), workers.MAX_AXES); + assert.equal(limits.get('maxAcknowledge'), workers.MAX_ACKNOWLEDGE); + assert.equal(limits.get('maxMessageCodePoints'), workers.MAX_MESSAGE_CODE_POINTS); + assert.equal(limits.get('maxViews'), media.MAX_VIEWS); + assert.equal(limits.get('maxViewDimension'), media.MAX_VIEW_DIMENSION); + assert.equal(limits.get('maxSampleFrames'), media.MAX_SAMPLE_FRAMES); + assert.equal(limits.get('maxAudioStreams'), media.MAX_AUDIO_STREAMS); + assert.equal(limits.get('maxTypedValueBytes'), scalar.MAX_TYPED_VALUE_BYTES); + assert.equal(limits.get('maxEnvelopeBytes'), canonical.MAX_ENVELOPE_BYTES); +}); + +test('the closed enums this package knows are the ones the schema set declares', async () => { + const set = parseStrict( + Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1), + ) as Record; + const enums = new Map( + (set.enums as Record[]).map((entry) => [ + entry.name as string, + entry.members as string[], + ]), + ); + const workers = await import('../src/workers'); + const rpc = await import('../src/rpc'); + assert.deepEqual(enums.get('ErrorCode'), [...rpc.ERROR_CODES]); + assert.deepEqual(enums.get('MutationCertainty'), [...rpc.MUTATION_CERTAINTIES]); + assert.deepEqual(enums.get('Role'), [...workers.ROLES]); + assert.deepEqual(enums.get('WorkerState'), [...workers.WORKER_STATES]); + assert.deepEqual(enums.get('Recovery'), [...workers.RECOVERY]); + assert.deepEqual(enums.get('Determinism'), [...workers.DETERMINISM]); + assert.deepEqual(enums.get('AxisRange'), [...workers.AXIS_RANGES]); +}); diff --git a/packages/session-types/tests/seeds.test.ts b/packages/session-types/tests/seeds.test.ts new file mode 100644 index 0000000..9ed59f9 --- /dev/null +++ b/packages/session-types/tests/seeds.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as fixtures from '../src/fixtures'; +import * as seed from '../src/seed'; + +test('every vector derives its recorded seed', () => { + const file = fixtures.load('seed-vectors.json'); + assert.equal((file as Record).algorithm, seed.ALGORITHM); + const vectors = fixtures.section(file, 'vectors'); + for (const item of vectors) { + const master = seed.masterSeed(fixtures.field(item, 'masterSeed')); + const agentId = fixtures.field(item, 'agentId'); + assert.equal( + Buffer.from(seed.material(master, agentId)).toString('utf8'), + fixtures.field(item, 'material'), + 'the hashed material is part of the specification', + ); + assert.equal(seed.materialDigest(master, agentId), fixtures.field(item, 'materialDigest')); + assert.equal( + seed.agentSeed(master, agentId), + (item as Record).seed, + `seed for ${agentId} under master ${master}`, + ); + } + assert.ok(vectors.length >= 20, 'keep the vector table broad'); +}); + +test('one composition gets independent seeds', () => { + const file = fixtures.load('seed-vectors.json'); + const composition = fixtures.member(file, 'composition'); + const master = seed.masterSeed(fixtures.field(composition, 'masterSeed')); + const ids = fixtures.section(composition, 'agentIds') as string[]; + const seeds = seed.compositionSeeds(master, ids); + assert.deepEqual(seeds, fixtures.section(composition, 'seeds')); + assert.equal(new Set(seeds).size, seeds.length, 'per-agent seeds are independent'); + assert.ok( + seeds.every((value) => value !== 0), + 'a zero seed would stall an xorshift generator', + ); +}); + +test('a different master seed or agent id derives a different seed', () => { + assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(1n, 'fly-a')); + assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(0n, 'fly-b')); + assert.equal(seed.agentSeed(7n, 'fly-a'), seed.agentSeed(7n, 'fly-a')); +}); + +test('invalid inputs are refused rather than normalized', () => { + const file = fixtures.load('seed-vectors.json'); + for (const item of fixtures.section(file, 'invalid')) { + const master = seed.masterSeed(fixtures.field(item, 'masterSeed')); + const agentId = fixtures.optionalField(item, 'agentId'); + if (agentId !== undefined) { + assert.throws(() => seed.agentSeed(master, agentId), `${agentId} must be refused`); + } else { + const ids = fixtures.section(item, 'agentIds') as string[]; + assert.throws(() => seed.compositionSeeds(master, ids)); + } + } +}); diff --git a/packages/session-types/tests/traces.test.ts b/packages/session-types/tests/traces.test.ts new file mode 100644 index 0000000..9ca16a4 --- /dev/null +++ b/packages/session-types/tests/traces.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as fixtures from '../src/fixtures'; +import { + behaviourDiff, + behaviourDigest, + behaviourEquals, + readTransitionTrace, + runsEqual, +} from '../src/trace'; + +test('every variant compares the way the fixture says', () => { + const file = fixtures.load('traces.json'); + const baseline = readTransitionTrace(fixtures.member(file, 'baseline')); + for (const item of fixtures.section(file, 'variants')) { + const name = fixtures.field(item, 'name'); + const variant = readTransitionTrace(fixtures.member(item, 'trace')); + const expected = (item as Record).behaviourEquals as boolean; + const diff = behaviourDiff(baseline, variant); + assert.equal( + behaviourEquals(baseline, variant), + expected, + `${name}: behaviour equality. differences: ${JSON.stringify(diff)}`, + ); + assert.equal(diff.length === 0, expected, `${name}: the diff is empty exactly when equal`); + const needle = fixtures.optionalField(item, 'diffContains'); + if (needle !== undefined) { + assert.ok( + diff.some((line) => line.includes(needle)), + `${name}: the diff should name ${needle}, got ${JSON.stringify(diff)}`, + ); + } + if (expected) { + assert.equal( + behaviourDigest(baseline.behaviour), + behaviourDigest(variant.behaviour), + `${name}: equal behaviour has one digest`, + ); + } + } +}); + +test('a whole run compares transition by transition', () => { + const file = fixtures.load('traces.json'); + const baseline = readTransitionTrace(fixtures.member(file, 'baseline')); + const variants = fixtures.section(file, 'variants'); + const reversed = readTransitionTrace(fixtures.member(variants[0] as never, 'trace')); + const changed = readTransitionTrace( + fixtures.member( + variants.find((item) => fixtures.field(item, 'name') === 'one extra neural tick') as never, + 'trace', + ), + ); + assert.ok(runsEqual([baseline, baseline], [reversed, baseline])); + assert.ok(!runsEqual([baseline], [changed])); + assert.ok(!runsEqual([baseline], [baseline, baseline])); +}); + +test('operational metadata is recorded and excluded', () => { + const file = fixtures.load('traces.json'); + const baseline = readTransitionTrace(fixtures.member(file, 'baseline')); + assert.equal(baseline.operational.busCallIds.length, 3); + assert.equal(baseline.operational.prepareRequestIds.length, 2); + assert.equal(baseline.operational.deliveryIds.length, 2); + const retried = readTransitionTrace( + fixtures.member( + fixtures + .section(file, 'variants') + .find( + (item) => + fixtures.field(item, 'name') === + 'a safe retry with fresh bus callIds, delivery ids and wall time', + ) as never, + 'trace', + ), + ); + assert.notDeepEqual(baseline.operational, retried.operational); + assert.ok(behaviourEquals(baseline, retried)); +}); diff --git a/packages/session-types/tsconfig.json b/packages/session-types/tsconfig.json new file mode 100644 index 0000000..8b46885 --- /dev/null +++ b/packages/session-types/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true + }, + "include": ["src", "tests"] +} diff --git a/services/flysim/crates/fly-session-types/fixtures/boundaries.json b/services/flysim/crates/fly-session-types/fixtures/boundaries.json index e211549..b79d012 100644 --- a/services/flysim/crates/fly-session-types/fixtures/boundaries.json +++ b/services/flysim/crates/fly-session-types/fixtures/boundaries.json @@ -102,22 +102,28 @@ "reason": "an integral double prints without a fraction" }, { - "value": 0.1, - "canonical": "0.1", + "value": -17.0, + "canonical": "-17", "accept": true, "reason": "" }, { - "value": 1e+21, - "canonical": "1e+21", + "value": 0.1, + "canonical": "0.1", "accept": true, - "reason": "ECMAScript switches to exponent notation at 1e21" + "reason": "the shortest round-tripping form" + }, + { + "value": 0.30000000000000004, + "canonical": "0.30000000000000004", + "accept": true, + "reason": "shortest round-tripping form, not a rounded one" }, { "value": 1e-07, "canonical": "1e-7", "accept": true, - "reason": "" + "reason": "ECMAScript switches to exponent notation below 1e-6" }, { "value": 5e-324, @@ -126,10 +132,10 @@ "reason": "the smallest subnormal double" }, { - "value": 1.7976931348623157e+308, - "canonical": "1.7976931348623157e+308", + "value": 1234.5678, + "canonical": "1234.5678", "accept": true, - "reason": "the largest finite double" + "reason": "a fractional value of any magnitude is canonicalizable" }, { "value": 9007199254740991, @@ -137,17 +143,35 @@ "accept": true, "reason": "the largest exactly representable integer" }, + { + "value": -9007199254740991, + "canonical": "-9007199254740991", + "accept": true, + "reason": "" + }, { "value": 9007199254740993, "canonical": null, "accept": false, - "reason": "past the exact integer range, canonical JSON refuses it" + "reason": "an integral value past the exact range; counters are U64 strings" }, { - "value": 0.30000000000000004, - "canonical": "0.30000000000000004", - "accept": true, - "reason": "shortest round-tripping form, not a rounded one" + "value": 1e+21, + "canonical": null, + "accept": false, + "reason": "integral and far past the exact range; JSON.parse cannot tell it from the same digits written out" + }, + { + "value": 1.7976931348623157e+308, + "canonical": null, + "accept": false, + "reason": "integral and far past the exact range" + }, + { + "value": 1.5e+20, + "canonical": null, + "accept": false, + "reason": "1.5e20 is integral as a double, so it falls under the same rule as 1e21" } ] } \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/src/canonical.rs b/services/flysim/crates/fly-session-types/src/canonical.rs index 8b34210..8fae54c 100644 --- a/services/flysim/crates/fly-session-types/src/canonical.rs +++ b/services/flysim/crates/fly-session-types/src/canonical.rs @@ -5,9 +5,12 @@ //! sorted tree produces the same bytes), strings escaped the way `JSON.stringify` escapes //! them, no insignificant whitespace. A digest is the SHA-256 of those bytes, lowercase hex. //! -//! Numbers outside the exactly representable double range are refused rather than rounded: -//! every counter and clock in these contracts is a `U64` decimal string, so a JSON number -//! larger than 2^53-1 is a schema error, not something to canonicalize approximately. +//! A JSON number is canonicalizable when it is finite and, if it is integral, no larger in +//! magnitude than 2^53-1. Integers past that range are refused rather than rounded: every +//! counter and clock in these contracts is a `U64` decimal string, so a large JSON number is +//! a schema error. The rule is stated on the value, not on how it was written, because +//! `JSON.parse` cannot tell `1e21` from `1000000000000000000000`, and two implementations +//! that disagree about one number do not agree about any digest. use serde_json::{Number, Value}; use sha2::{Digest as _, Sha256}; @@ -20,15 +23,14 @@ pub const MAX_EXACT_INTEGER: i64 = 9_007_199_254_740_991; /// The bus envelope ceiling every domain message must also fit (bus-v1 section 4). pub const MAX_ENVELOPE_BYTES: usize = flybus::wire::MAX_ENVELOPE_BYTES; -/// The `f64` a JSON number denotes, or `None` if it is not a finite exactly representable one. +/// The `f64` a JSON number denotes, or `None` if it is not canonicalizable: not finite, or an +/// integral value outside the exactly representable integer range. pub fn finite_double(n: &Number) -> Option { - if let Some(u) = n.as_u64() { - return (u <= MAX_EXACT_INTEGER as u64).then_some(u as f64); + let value = n.as_f64().filter(|v| v.is_finite())?; + if value.fract() == 0.0 && value.abs() > MAX_EXACT_INTEGER as f64 { + return None; } - if let Some(i) = n.as_i64() { - return (i >= -MAX_EXACT_INTEGER).then_some(i as f64); - } - n.as_f64().filter(|v| v.is_finite()) + Some(value) } /// `String(number)` for a finite double, the ECMAScript algorithm RFC 8785 requires.