soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an
Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes
Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is
provisional until Bifrost's steward cuts the list.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-02 15:46:03 +00:00
commit cc99a3439d
3 changed files with 97 additions and 32 deletions

View file

@ -1,6 +1,7 @@
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510,
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9,
// section 2). The Heimdall session produces it; this file only reads it. Shape follows
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it.
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human
// subset below is PROVISIONAL until Bifrost's steward cuts the list.
export type Estate = 'human' | 'mind' | 'persona';
export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert';
@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
export type Size = 'S' | 'M' | 'L';
export type Override = 'None' | 'Muted';
export interface ActivityFrame {
interface FrameBase {
mind: string;
estate: Estate;
kind: Kind;
energy: Energy;
size: Size;
neutral: boolean;
override: Override;
seq: number;
/** Producer observation time, whole seconds (grammar: 1 s resolution). */
at: number;
}
/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */
export interface NeutralFrame extends FrameBase {
neutral: true;
kind?: undefined;
energy?: undefined;
}
export interface LiveFrame extends FrameBase {
neutral: false;
kind: Kind;
energy: Energy;
}
export type ActivityFrame = NeutralFrame | LiveFrame;
const ESTATES: readonly Estate[] = ['human', 'mind', 'persona'];
const KINDS: readonly Kind[] = [
@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10;
const oneOf = <T extends string>(set: readonly T[], v: unknown): T | undefined =>
set.find((s) => s === v);
/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */
export const clampEnergy = (estate: Estate, energy: Energy): Energy =>
/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */
export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking'];
/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */
const clampKind = (estate: Estate, kind: unknown): Kind | undefined =>
estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind);
/** A human's energy is three buckets: Peak reads as High. */
const clampEnergy = (estate: Estate, energy: Energy): Energy =>
estate === 'human' && energy === 'Peak' ? 'High' : energy;
/**
* Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a
* room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3).
* Returns undefined for anything malformed; the caller treats that as no frame (A4).
* `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent
* them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4).
*/
export const readFrame = (raw: unknown): ActivityFrame | undefined => {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
if (typeof r.neutral !== 'boolean') return undefined;
const estate = oneOf(ESTATES, r.estate);
const kind = oneOf(KINDS, r.kind);
const energy = oneOf(ENERGIES, r.energy);
const size = oneOf(SIZES, r.size);
const override =
r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override);
if (!estate || !kind || !energy || !size || !override) return undefined;
if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean')
return undefined;
if (!estate || !size || !override) return undefined;
if (typeof r.mind !== 'string' || r.mind === '') return undefined;
if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined;
return {
mind: r.mind,
estate,
kind,
energy: clampEnergy(estate, energy),
size,
neutral: r.neutral,
override,
seq: r.seq as number,
at: r.at as number,
};
const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number };
if (r.neutral) return { ...base, neutral: true };
const kind = clampKind(estate, r.kind);
const energy = oneOf(ENERGIES, r.energy);
if (!kind || !energy) return undefined;
return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) };
};
/** Latest frame per mind (S1: consumers drop seq <= last). */

View file

@ -1,11 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame';
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame';
import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene';
import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices';
import { isWeakDevice } from './device';
import { createAudioSocket, silentSource, type ActivitySource } from './socket';
const frame = (o: Partial<ActivityFrame> = {}): ActivityFrame => ({
const frame = (o: Partial<LiveFrame> = {}): LiveFrame => ({
mind: 'void',
estate: 'mind',
kind: 'Working',
@ -17,6 +17,11 @@ const frame = (o: Partial<ActivityFrame> = {}): ActivityFrame => ({
at: 100,
...o,
});
const neutralFrame = (o: Partial<ActivityFrame> = {}): ActivityFrame =>
({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame);
/** A raw frame as a producer sends it, with the named keys omitted. */
const without = (f: object, ...keys: string[]) =>
Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k)));
const noop = () => undefined;
const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 };
@ -25,9 +30,7 @@ describe('neutral overrides everything', () => {
const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) =>
(['Speaking', 'Alert', 'Working'] as const).flatMap((kind) =>
(['High', 'Peak'] as const).flatMap((energy) =>
(['S', 'M', 'L'] as const).map((size) =>
frame({ neutral: true, estate, kind, energy, size })
)
(['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size }))
)
)
);
@ -45,6 +48,55 @@ describe('neutral overrides everything', () => {
});
});
describe('neutral is read first', () => {
it('yields no kind and no energy, even when the producer sent them', () => {
(['human', 'mind', 'persona'] as const).forEach((estate) => {
const f = readFrame({
...frame({ estate, kind: 'Speaking', energy: 'High' }),
neutral: true,
});
expect(f).toBeDefined();
expect(f?.neutral).toBe(true);
expect(f?.kind).toBeUndefined();
expect(f?.energy).toBeUndefined();
expect(Object.keys(f ?? {})).not.toContain('kind');
expect(Object.keys(f ?? {})).not.toContain('energy');
});
});
it('a neutral human with kind omitted is not an Idle', () => {
const bare = without(frame({ estate: 'human' }), 'kind', 'energy');
const f = readFrame({ ...bare, neutral: true });
expect(f?.kind).toBeUndefined();
expect(drawnCreatures([f as ActivityFrame])).toEqual([]);
expect(planVoices([f as ActivityFrame], on)).toEqual([]);
});
});
describe('human kinds are the provisional subset', () => {
it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => {
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle');
});
it('keeps Idle, Working and Speaking', () => {
(['Idle', 'Working', 'Speaking'] as const).forEach((kind) =>
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind)
);
});
it('an omitted kind is Idle for a non-neutral human only', () => {
const noKind = without(frame({ estate: 'human' }), 'kind');
expect(readFrame(noKind)?.kind).toBe('Idle');
expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined();
});
it('minds and personas keep all seven kinds, and omitted is malformed for them', () => {
(['mind', 'persona'] as const).forEach((estate) => {
(['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach(
(kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind)
);
const noKind = without(frame({ estate }), 'kind');
expect(readFrame(noKind)).toBeUndefined();
});
});
});
describe('human energy is clamped to three buckets by the consumer', () => {
it('turns Peak into High for humans only', () => {
expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High');

View file

@ -1,4 +1,4 @@
import type { ActivityFrame, Energy, Kind } from './frame';
import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame';
/** Grammar V3 (proposed, unmeasured): voices audible at once. */
export const DEFAULT_VOICE_CAP = 4;
@ -42,7 +42,9 @@ export const planVoices = (
if (gain === 0) return [];
const cap = Math.max(0, Math.floor(prefs.voiceCap));
return frames
.filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind))
.filter(
(f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)
)
.sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind))
.slice(0, cap)
.map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain }));