@pose_flow/babylon 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,24 @@
1
1
  import { PoseSearch } from './search.js';
2
2
  import type { Database, Match, Query } from './types.js';
3
+ import type { SearchReport } from './search.js';
3
4
  export interface Playback {
4
5
  clip: number;
5
6
  time: number;
6
7
  switched: boolean;
7
8
  match: Match | null;
8
9
  }
10
+ export interface MatcherDecision {
11
+ searched: boolean;
12
+ reason: string;
13
+ previousClip: number;
14
+ currentClip: number;
15
+ currentCost: number | null;
16
+ candidateCost: number | null;
17
+ sinceSwitch: number;
18
+ minimumHold: number;
19
+ jumpPenalty: number;
20
+ improvement: number;
21
+ }
9
22
  export interface MatcherOptions {
10
23
  searchInterval: number;
11
24
  minimumHold: number;
@@ -13,6 +26,12 @@ export interface MatcherOptions {
13
26
  jumpPenalty: number;
14
27
  timeSeparation: number;
15
28
  reselectDelay: number;
29
+ /** Active only when the query explicitly requests idle alone. */
30
+ idleVariation?: {
31
+ interval: number;
32
+ historySize: number;
33
+ };
34
+ diagnostics?: boolean;
16
35
  }
17
36
  /** Owns animation time only. Caller retains movement and action authority. */
18
37
  export declare class MotionMatcher {
@@ -26,6 +45,13 @@ export declare class MotionMatcher {
26
45
  private last;
27
46
  private completed;
28
47
  private indices;
48
+ private idleElapsed;
49
+ private idleHistory;
50
+ private effectiveQuery?;
51
+ private decisionState;
52
+ /** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
53
+ get decision(): MatcherDecision | null;
54
+ inspect(limit?: number): SearchReport | null;
29
55
  constructor(database: Database, options?: Partial<MatcherOptions>);
30
56
  reset(): void;
31
57
  update(dt: number, query: Query): Playback;
@@ -1,4 +1,5 @@
1
1
  import { PoseSearch } from './search.js';
2
+ import { withinWindows } from './selection.js';
2
3
  /** Owns animation time only. Caller retains movement and action authority. */
3
4
  export class MotionMatcher {
4
5
  database;
@@ -11,22 +12,36 @@ export class MotionMatcher {
11
12
  last = null;
12
13
  completed = new Map();
13
14
  indices;
15
+ idleElapsed = 0;
16
+ idleHistory = [];
17
+ effectiveQuery;
18
+ decisionState = null;
19
+ /** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
20
+ get decision() { return this.decisionState ? { ...this.decisionState } : null; }
21
+ inspect(limit = 5) { return this.options.diagnostics && this.effectiveQuery ? this.search.inspect(this.effectiveQuery, limit) : null; }
14
22
  constructor(database, options = {}) {
15
23
  this.database = database;
16
24
  this.search = new PoseSearch(database);
17
25
  this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
18
- if (!Object.values(this.options).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
26
+ const { idleVariation, diagnostics, ...numeric } = this.options;
27
+ if (!Object.values(numeric).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
19
28
  throw new Error('Invalid matcher options');
29
+ if (idleVariation && (!Number.isFinite(idleVariation.interval) || idleVariation.interval <= 0 || !Number.isInteger(idleVariation.historySize) || idleVariation.historySize < 1))
30
+ throw new Error('Invalid idle variation policy');
31
+ if (diagnostics !== undefined && typeof diagnostics !== 'boolean')
32
+ throw new Error('Invalid diagnostics option');
20
33
  this.indices = database.manifest.clips.map((_, clip) => database.manifest.frames.flatMap((f, i) => f.clip === clip ? [i] : []).sort((a, b) => database.manifest.frames[a].time - database.manifest.frames[b].time));
21
34
  if (this.indices.some(i => !i.length))
22
35
  throw new Error('Every clip must have indexed samples');
23
36
  }
24
- reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
37
+ reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); this.idleElapsed = 0; this.idleHistory = []; this.effectiveQuery = undefined; this.decisionState = null; }
25
38
  update(dt, query) {
26
39
  if (!Number.isFinite(dt) || dt < 0)
27
40
  throw new Error('Invalid delta time');
28
41
  // Validate identity even while continuity skips a search this frame.
29
42
  this.search.validateQuery(query);
43
+ const previousClip = this.clip;
44
+ let reason = 'search-interval', searched = false, currentCost = null, candidateCost = null;
30
45
  let switched = false, ended = false;
31
46
  for (const [clip, remaining] of this.completed) {
32
47
  if (remaining <= dt)
@@ -42,15 +57,47 @@ export class MotionMatcher {
42
57
  }
43
58
  this.sinceSearch += dt;
44
59
  this.sinceSwitch += dt;
45
- const allowed = this.clip >= 0 && !query.excludedClips?.has(this.clip) && (!query.gaits || query.gaits.has(this.database.manifest.clips[this.clip].gait));
60
+ const allowed = this.search.allowsClip(this.clip, query);
61
+ const idleOnly = query.gaits?.size === 1 && query.gaits.has('idle') && !!this.options.idleVariation;
62
+ if (idleOnly && allowed)
63
+ this.idleElapsed += dt;
64
+ else {
65
+ this.idleElapsed = 0;
66
+ this.idleHistory = [];
67
+ }
46
68
  if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
69
+ searched = true;
47
70
  this.sinceSearch = 0;
48
71
  // A finished one-shot must yield to another clip, rather than selecting
49
72
  // its terminal pose and restarting forever. User exclusions still apply.
50
73
  if (ended)
51
74
  this.completed.set(this.clip, this.options.reselectDelay);
52
75
  const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
53
- const best = this.search.search({ ...query, excludedClips });
76
+ let variation = false;
77
+ let best = null;
78
+ const runSearch = (effective) => {
79
+ // Keep the query that actually produced the candidate, including idle
80
+ // history/completion exclusions. Features are snapshotted for later UI.
81
+ if (this.options.diagnostics)
82
+ this.effectiveQuery = { ...effective, features: effective.features.slice(), context: effective.context ? { ...effective.context } : undefined,
83
+ gaits: effective.gaits ? new Set(effective.gaits) : undefined, excludedClips: effective.excludedClips ? new Set(effective.excludedClips) : undefined };
84
+ return this.search.search(effective);
85
+ };
86
+ if (idleOnly && allowed && !ended) {
87
+ if (this.idleElapsed >= this.options.idleVariation.interval) {
88
+ // Relax oldest history first when the pool is small, but never force
89
+ // a restart of the current clip or relax the consumer's context.
90
+ const history = [...this.idleHistory];
91
+ do {
92
+ best = runSearch({ ...query, excludedClips: new Set([...excludedClips, this.clip, ...history]) });
93
+ } while (!best && history.shift() !== undefined);
94
+ variation = best !== null;
95
+ }
96
+ if (!best)
97
+ best = runSearch({ ...query, excludedClips: new Set([...excludedClips, ...this.database.manifest.clips.flatMap((_, i) => i === this.clip ? [] : [i])]) });
98
+ }
99
+ else
100
+ best = runSearch({ ...query, excludedClips });
54
101
  this.last = best;
55
102
  if (best) {
56
103
  const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
@@ -60,21 +107,39 @@ export class MotionMatcher {
60
107
  gap = Math.min(gap, duration - gap);
61
108
  const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
62
109
  const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
110
+ currentCost = current < 0 ? null : this.search.cost(current);
111
+ candidateCost = best.cost;
63
112
  const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
64
113
  && this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
65
114
  const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
66
- if (current < 0 || !allowed || ended || (mayReselectTime && (incompatibleVelocity || (distant && better && this.sinceSwitch >= this.options.minimumHold)))) {
115
+ const mayExit = this.clip < 0 || withinWindows(this.time, this.database.manifest.clips[this.clip].exitWindows);
116
+ const shouldSwitch = current < 0 || !allowed || ended || (mayExit && mayReselectTime && (incompatibleVelocity || (distant && (better || variation) && this.sinceSwitch >= this.options.minimumHold)));
117
+ reason = current < 0 ? 'initial' : !allowed ? 'incompatible-current' : ended ? 'clip-ended' : !mayExit ? 'exit-window' : !mayReselectTime ? 'phase-preserved'
118
+ : incompatibleVelocity ? 'velocity-incompatible' : !distant ? 'nearby-candidate' : this.sinceSwitch < this.options.minimumHold ? 'minimum-hold' : variation ? 'idle-variation' : better ? 'better-candidate' : 'insufficient-improvement';
119
+ if (shouldSwitch) {
67
120
  this.clip = best.clip;
68
121
  this.time = best.time;
69
122
  this.sinceSwitch = 0;
70
123
  switched = true;
124
+ this.idleElapsed = 0;
125
+ if (this.database.manifest.clips[this.clip].gait === 'idle') {
126
+ this.idleHistory.push(this.clip);
127
+ this.idleHistory = this.idleHistory.slice(-(this.options.idleVariation?.historySize ?? 1));
128
+ }
129
+ else
130
+ this.idleHistory = [];
71
131
  }
72
132
  }
73
- else if (!allowed || ended) {
74
- this.clip = -1;
75
- this.time = 0;
133
+ else {
134
+ reason = !allowed || ended ? 'no-candidate-cleared' : 'no-entry-continue';
135
+ if (!allowed || ended) {
136
+ this.clip = -1;
137
+ this.time = 0;
138
+ }
76
139
  }
77
140
  }
141
+ this.decisionState = { searched, reason, previousClip, currentClip: this.clip, currentCost, candidateCost, sinceSwitch: this.sinceSwitch,
142
+ minimumHold: this.options.minimumHold, improvement: this.options.improvement, jumpPenalty: this.options.jumpPenalty };
78
143
  return { clip: this.clip, time: this.time, switched, match: this.last };
79
144
  }
80
145
  nearestFrame(clip, time) {
@@ -1,5 +1,6 @@
1
1
  import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
2
2
  import { parseRootOrientation } from './profile.js';
3
+ import { validateSelection } from './selection.js';
3
4
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
4
5
  export function validateDatabase(db) {
5
6
  const m = db?.manifest;
@@ -30,6 +31,7 @@ export function validateDatabase(db) {
30
31
  || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
31
32
  throw new Error('Invalid clip');
32
33
  ids.add(clip.id);
34
+ validateSelection(clip, clip.duration);
33
35
  if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
34
36
  throw new Error('Invalid source start');
35
37
  if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
@@ -0,0 +1,3 @@
1
+ /** Critically damped residual. With zero initial velocity, offset halves at halfLife.
2
+ * Does not integrate gameplay/root displacement. */
3
+ export declare function decayInertialOffset(offset: number, velocity: number, elapsed: number, halfLife: number): number;
@@ -0,0 +1,15 @@
1
+ /** Critically damped residual. With zero initial velocity, offset halves at halfLife.
2
+ * Does not integrate gameplay/root displacement. */
3
+ export function decayInertialOffset(offset, velocity, elapsed, halfLife) {
4
+ if (![offset, velocity, elapsed, halfLife].every(Number.isFinite) || elapsed < 0 || halfLife <= 0)
5
+ throw new Error('Invalid inertial residual');
6
+ if (elapsed === 0)
7
+ return offset;
8
+ const exponent = (1.67834699001666 / halfLife) * elapsed;
9
+ if (exponent > 745)
10
+ return 0;
11
+ const decay = Math.exp(-exponent);
12
+ // Reassociate products: avoid Infinity * 0 after a long pause, and avoid
13
+ // overflowing omega*offset while the final damped value is representable.
14
+ return offset * ((1 + exponent) * decay) + velocity * (elapsed * decay);
15
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Gait, Quat, Vec3 } from './types.js';
2
2
  import { type PoseSchema } from './schema.js';
3
+ import { type SelectionMetadata, type SelectionContext } from './selection.js';
3
4
  export interface CharacterProfile {
4
5
  version: 2;
5
6
  id: string;
@@ -18,11 +19,19 @@ export interface CharacterProfile {
18
19
  boneMap?: Record<string, string>;
19
20
  /** Model node whose position/heading tracks are excluded by the player. */
20
21
  stripRoot?: string;
22
+ /** Exact model node names for optional runtime layers. */
23
+ masks?: Record<string, readonly string[]>;
24
+ /** Lab-only presentation preview. Does not authorize a gameplay action. */
25
+ layerPreview?: {
26
+ baseMask: string;
27
+ overlayMask: string;
28
+ context: SelectionContext;
29
+ };
21
30
  }
22
31
  export interface AnimationSet {
23
32
  version: 1;
24
33
  id: string;
25
- clips: {
34
+ clips: (SelectionMetadata & {
26
35
  id: string;
27
36
  file: string;
28
37
  animation: string;
@@ -33,7 +42,7 @@ export interface AnimationSet {
33
42
  rootOrientation?: Quat;
34
43
  /** Source interval in seconds. */
35
44
  range?: readonly [number, number];
36
- }[];
45
+ })[];
37
46
  }
38
47
  export interface ResolvedProfile extends CharacterProfile {
39
48
  schemaDefinition: PoseSchema;
@@ -1,4 +1,5 @@
1
1
  import { compileSchema, parseSchema } from './schema.js';
2
+ import { validateContext, validateSelection } from './selection.js';
2
3
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
3
4
  const name = (v) => typeof v === 'string' && v.trim().length > 0;
4
5
  const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
@@ -25,6 +26,18 @@ export function parseProfile(input) {
25
26
  throw new Error('Profile: required bones must be distinct');
26
27
  if (input.stripRoot !== undefined && !name(input.stripRoot))
27
28
  throw new Error('Profile: invalid stripRoot');
29
+ if (input.masks !== undefined && (!record(input.masks) || !Object.entries(input.masks).length || !Object.entries(input.masks).every(([key, values]) => name(key)
30
+ && Array.isArray(values) && values.length > 0 && values.every(name) && new Set(values).size === values.length)))
31
+ throw new Error('Profile: invalid masks');
32
+ if (input.layerPreview !== undefined) {
33
+ const preview = input.layerPreview, masks = input.masks;
34
+ if (!record(preview) || !name(preview.baseMask) || !name(preview.overlayMask) || !masks || !Object.hasOwn(masks, preview.baseMask) || !Object.hasOwn(masks, preview.overlayMask)
35
+ || masks[preview.baseMask].some(node => masks[preview.overlayMask].includes(node)))
36
+ throw new Error('Profile: layer preview requires two disjoint masks');
37
+ validateContext(preview.context);
38
+ if (!Object.keys(preview.context).length)
39
+ throw new Error('Profile: layer preview requires explicit context');
40
+ }
28
41
  if (input.boneMap !== undefined && (!record(input.boneMap) || !Object.entries(input.boneMap).every(([k, v]) => name(k) && name(v)) || new Set(Object.values(input.boneMap)).size !== Object.values(input.boneMap).length))
29
42
  throw new Error('Profile: boneMap must map distinct nonempty names');
30
43
  return structuredClone(input);
@@ -42,6 +55,7 @@ export function parseAnimationSet(input) {
42
55
  parseRootOrientation(clip.rootOrientation);
43
56
  if (clip.range !== undefined && (!Array.isArray(clip.range) || clip.range.length !== 2 || !clip.range.every(Number.isFinite) || clip.range[0] < 0 || clip.range[1] <= clip.range[0]))
44
57
  throw new Error('Invalid clip range');
58
+ validateSelection(clip, Array.isArray(clip.range) ? clip.range[1] - clip.range[0] : Infinity);
45
59
  const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
46
60
  if (nominal === measured)
47
61
  throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
@@ -0,0 +1,29 @@
1
+ import { type MatcherOptions, type Playback } from './controller.js';
2
+ import type { Database, Gait, Query, Vec3 } from './types.js';
3
+ export interface RecordedQuery extends Omit<Query, 'features' | 'gaits' | 'excludedClips'> {
4
+ features: number[];
5
+ gaits?: Gait[];
6
+ excludedClips?: number[];
7
+ }
8
+ export interface ScenarioFrame {
9
+ dt: number;
10
+ query: RecordedQuery;
11
+ controller: {
12
+ position: Vec3;
13
+ yaw: number;
14
+ };
15
+ playback: Omit<Playback, 'match'>;
16
+ presentation?: Omit<Playback, 'match'>;
17
+ }
18
+ export interface MotionScenario {
19
+ version: 1;
20
+ databaseIdentity: string;
21
+ matcherOptions: Partial<MatcherOptions>;
22
+ frames: ScenarioFrame[];
23
+ }
24
+ /** Content identity for exact replay, not a cryptographic/security signature. */
25
+ export declare function scenarioDatabaseIdentity(db: Database): string;
26
+ export declare function restoreScenarioQuery(recorded: RecordedQuery): Query;
27
+ export declare function captureScenarioFrame(dt: number, query: Query, position: Vec3, yaw: number, playback: Playback, presentation?: Playback): ScenarioFrame;
28
+ /** Import only validated local data, never execute content from a scenario file. */
29
+ export declare function parseMotionScenario(input: unknown, db: Database): MotionScenario;
@@ -0,0 +1,57 @@
1
+ import { encodeFeatures } from './database.js';
2
+ import { MotionMatcher } from './controller.js';
3
+ import { PoseSearch } from './search.js';
4
+ /** Content identity for exact replay, not a cryptographic/security signature. */
5
+ export function scenarioDatabaseIdentity(db) {
6
+ let hash = 2166136261;
7
+ for (const char of JSON.stringify(db.manifest))
8
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
9
+ for (const byte of new Uint8Array(encodeFeatures(db)))
10
+ hash = Math.imul(hash ^ byte, 16777619);
11
+ return 'scenario-db-v1:' + (hash >>> 0).toString(16).padStart(8, '0');
12
+ }
13
+ export function restoreScenarioQuery(recorded) {
14
+ return { ...recorded, features: Float32Array.from(recorded.features), gaits: recorded.gaits ? new Set(recorded.gaits) : undefined,
15
+ excludedClips: recorded.excludedClips ? new Set(recorded.excludedClips) : undefined };
16
+ }
17
+ export function captureScenarioFrame(dt, query, position, yaw, playback, presentation) {
18
+ return { dt, query: { ...query, features: [...query.features], ...(query.context ? { context: { ...query.context } } : {}),
19
+ gaits: query.gaits ? [...query.gaits] : undefined, excludedClips: query.excludedClips ? [...query.excludedClips] : undefined },
20
+ controller: { position: [...position], yaw }, playback: { clip: playback.clip, time: playback.time, switched: playback.switched },
21
+ ...(presentation ? { presentation: { clip: presentation.clip, time: presentation.time, switched: presentation.switched } } : {}) };
22
+ }
23
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
24
+ /** Import only validated local data, never execute content from a scenario file. */
25
+ export function parseMotionScenario(input, db) {
26
+ if (!record(input) || input.version !== 1 || input.databaseIdentity !== scenarioDatabaseIdentity(db) || !Array.isArray(input.frames)
27
+ || !input.frames.length || input.frames.length > 6000 || input.frames.length * db.manifest.dimensions > 2_000_000 || !record(input.matcherOptions))
28
+ throw new Error('Invalid/incompatible motion scenario');
29
+ const options = input.matcherOptions;
30
+ if (Object.keys(options).some(k => !['searchInterval', 'minimumHold', 'improvement', 'jumpPenalty', 'timeSeparation', 'reselectDelay', 'idleVariation', 'diagnostics'].includes(k)))
31
+ throw new Error('Invalid scenario matcher options');
32
+ new MotionMatcher(db, options);
33
+ const search = new PoseSearch(db);
34
+ for (const frame of input.frames) {
35
+ if (!record(frame) || typeof frame.dt !== 'number' || !Number.isFinite(frame.dt) || frame.dt < 0 || frame.dt > 10 || !record(frame.query)
36
+ || !record(frame.controller) || !record(frame.playback))
37
+ throw new Error('Invalid scenario frame');
38
+ const q = frame.query, p = frame.playback, controller = frame.controller;
39
+ if (Object.keys(q).some(k => !['features', 'schemaSignature', 'gaits', 'excludedClips', 'velocityTolerance', 'allowTemporalReselection', 'context'].includes(k))
40
+ || !Array.isArray(q.features) || q.features.length !== db.manifest.dimensions || !q.features.every(Number.isFinite)
41
+ || (q.gaits !== undefined && (!Array.isArray(q.gaits) || !q.gaits.every(g => ['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(g))))
42
+ || (q.excludedClips !== undefined && (!Array.isArray(q.excludedClips) || !q.excludedClips.every(c => Number.isInteger(c) && c >= 0 && c < db.manifest.clips.length))))
43
+ throw new Error('Invalid scenario query');
44
+ search.validateQuery(restoreScenarioQuery(q));
45
+ if (!Array.isArray(controller.position) || controller.position.length !== 3 || !controller.position.every(Number.isFinite) || typeof controller.yaw !== 'number' || !Number.isFinite(controller.yaw)
46
+ || !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
47
+ || (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
48
+ throw new Error('Invalid scenario playback/controller');
49
+ if (frame.presentation !== undefined) {
50
+ const p = frame.presentation;
51
+ if (!record(p) || !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
52
+ || (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
53
+ throw new Error('Invalid scenario presentation');
54
+ }
55
+ }
56
+ return structuredClone(input);
57
+ }
@@ -1,6 +1,33 @@
1
1
  import { type Database, type Match, type Query } from './types.js';
2
2
  import { type PoseSchema } from './schema.js';
3
3
  export declare function defaultWeights(schema?: PoseSchema): Float32Array;
4
+ export type RejectionReason = 'excluded-clip' | 'gait' | 'context' | 'entry-window' | 'excluded-window' | 'velocity';
5
+ export interface CandidateCost extends Match {
6
+ channels: {
7
+ id: string;
8
+ cost: number;
9
+ }[];
10
+ velocityError: number | null;
11
+ }
12
+ export interface SearchReport {
13
+ best: Match | null;
14
+ candidates: CandidateCost[];
15
+ rejected: Record<RejectionReason, number>;
16
+ examples: {
17
+ index: number;
18
+ clip: number;
19
+ time: number;
20
+ reason: RejectionReason;
21
+ }[];
22
+ eligible: number;
23
+ total: number;
24
+ velocityLimit: number | null;
25
+ }
26
+ export declare function readCandidateTrajectory(database: Database, index: number): {
27
+ horizon: number;
28
+ position?: readonly [number, number];
29
+ facing?: readonly [number, number];
30
+ }[];
4
31
  export declare class PoseSearch {
5
32
  readonly database: Database;
6
33
  private normalized;
@@ -9,9 +36,14 @@ export declare class PoseSearch {
9
36
  readonly weights: Float32Array;
10
37
  constructor(database: Database, weights?: Float32Array);
11
38
  validateQuery(query: Query): void;
39
+ /** Context/gait validity is separate from eligibility to enter at a sample. */
40
+ allowsClip(clip: number, query: Query): boolean;
41
+ allowsFrame(index: number, query: Query): boolean;
12
42
  normalize(query: Query): void;
13
43
  cost(index: number): number;
14
44
  /** Physical error, independent of normalization/pose weights. Query must have been validated. */
15
45
  rootVelocityError(index: number, query: Query): number;
16
46
  search(query: Query): Match | null;
47
+ /** Opt-in diagnostic pass. Bounded output, exact ranking, no change to filters/weights. */
48
+ inspect(query: Query, limit?: number): SearchReport;
17
49
  }
@@ -1,5 +1,6 @@
1
1
  import { validateDatabase } from './database.js';
2
2
  import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
3
+ import { matchesContext, validateContext, withinWindows } from './selection.js';
3
4
  export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
4
5
  const layout = compileSchema(schema), weights = new Float32Array(layout.dimensions);
5
6
  for (const { channel, offset, dimensions } of layout.channels)
@@ -9,6 +10,20 @@ export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
9
10
  return weights;
10
11
  }
11
12
  const legacySignature = compileSchema(LOCOMOTION_SCHEMA).signature;
13
+ export function readCandidateTrajectory(database, index) {
14
+ if (!Number.isInteger(index) || index < 0 || index >= database.manifest.frames.length)
15
+ throw new Error('Invalid candidate frame');
16
+ const { dimensions, mean, std } = database.manifest, layout = compileSchema(database.manifest.schema);
17
+ const points = new Map(layout.horizons.map(horizon => [horizon, { horizon }]));
18
+ for (const { channel, offset } of layout.channels)
19
+ if (channel.kind === 'trajectory-position' || channel.kind === 'trajectory-facing') {
20
+ channel.horizons.forEach((horizon, h) => {
21
+ const j = offset + h * 2, value = [database.features[index * dimensions + j] * std[j] + mean[j], database.features[index * dimensions + j + 1] * std[j + 1] + mean[j + 1]];
22
+ points.get(horizon)[channel.kind === 'trajectory-position' ? 'position' : 'facing'] = value;
23
+ });
24
+ }
25
+ return [...points.values()];
26
+ }
12
27
  export class PoseSearch {
13
28
  database;
14
29
  normalized;
@@ -27,6 +42,8 @@ export class PoseSearch {
27
42
  this.weights = weights.slice();
28
43
  }
29
44
  validateQuery(query) {
45
+ if (query.context !== undefined)
46
+ validateContext(query.context);
30
47
  if (query.schemaSignature !== this.database.manifest.schemaSignature && !(query.schemaSignature === undefined && this.database.manifest.schemaSignature === legacySignature))
31
48
  throw new Error('Incompatible query schema');
32
49
  if (query.features.length !== this.dimensions || !query.features.every(Number.isFinite))
@@ -36,6 +53,16 @@ export class PoseSearch {
36
53
  if (query.velocityTolerance !== undefined && (!Number.isFinite(query.velocityTolerance) || query.velocityTolerance < 0 || this.rootOffset === undefined))
37
54
  throw new Error('Velocity tolerance requires a root-velocity channel and a finite nonnegative value');
38
55
  }
56
+ /** Context/gait validity is separate from eligibility to enter at a sample. */
57
+ allowsClip(clip, query) {
58
+ const descriptor = this.database.manifest.clips[clip];
59
+ return !!descriptor && !query.excludedClips?.has(clip) && (!query.gaits || query.gaits.has(descriptor.gait)) && (!query.context || matchesContext(descriptor.context, query.context));
60
+ }
61
+ allowsFrame(index, query) {
62
+ const frame = this.database.manifest.frames[index], clip = this.database.manifest.clips[frame.clip];
63
+ return this.allowsClip(frame.clip, query) && withinWindows(frame.time, clip.entryWindows)
64
+ && (clip.excludedWindows === undefined || !withinWindows(frame.time, clip.excludedWindows));
65
+ }
39
66
  normalize(query) {
40
67
  this.validateQuery(query);
41
68
  const { mean, std } = this.database.manifest;
@@ -66,24 +93,28 @@ export class PoseSearch {
66
93
  search(query) {
67
94
  this.normalize(query);
68
95
  const { frames, clips } = this.database.manifest;
96
+ // Context is clip-level: resolve it once per clip, rather than once per
97
+ // sample in each of the velocity and ranking passes.
98
+ const eligibleClips = clips.map((_, clip) => this.allowsClip(clip, query));
99
+ const candidates = [];
100
+ for (let i = 0; i < frames.length; i++) {
101
+ const frame = frames[i], clip = clips[frame.clip];
102
+ if (eligibleClips[frame.clip] && withinWindows(frame.time, clip.entryWindows)
103
+ && (clip.excludedWindows === undefined || !withinWindows(frame.time, clip.excludedWindows)))
104
+ candidates.push(i);
105
+ }
69
106
  let index = -1, cost = Infinity;
70
107
  let velocityLimit = Infinity;
71
108
  if (query.velocityTolerance !== undefined) {
72
109
  let nearest = Infinity;
73
- for (let i = 0; i < frames.length; i++) {
74
- if (query.excludedClips?.has(frames[i].clip) || (query.gaits && !query.gaits.has(clips[frames[i].clip].gait)))
75
- continue;
110
+ for (const i of candidates) {
76
111
  nearest = Math.min(nearest, this.rootVelocityError(i, query));
77
112
  }
78
113
  // Sparse datasets retain a deterministic nearest-velocity fallback. Never
79
114
  // expand the pool to arbitrary stationary poses just because coverage is missing.
80
115
  velocityLimit = Math.max(query.velocityTolerance, nearest + 1e-6);
81
116
  }
82
- for (let i = 0; i < frames.length; i++) {
83
- if (query.excludedClips?.has(frames[i].clip))
84
- continue;
85
- if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
86
- continue;
117
+ for (const i of candidates) {
87
118
  if (query.velocityTolerance !== undefined && this.rootVelocityError(i, query) > velocityLimit)
88
119
  continue;
89
120
  const score = this.cost(i);
@@ -94,4 +125,57 @@ export class PoseSearch {
94
125
  }
95
126
  return index < 0 ? null : { ...frames[index], index, cost };
96
127
  }
128
+ /** Opt-in diagnostic pass. Bounded output, exact ranking, no change to filters/weights. */
129
+ inspect(query, limit = 5) {
130
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
131
+ throw new Error('Invalid diagnostic limit');
132
+ const best = this.search(query), { frames, clips } = this.database.manifest;
133
+ const rejected = { 'excluded-clip': 0, gait: 0, context: 0, 'entry-window': 0, 'excluded-window': 0, velocity: 0 };
134
+ const examples = [], candidates = [];
135
+ const layout = compileSchema(this.database.manifest.schema);
136
+ let nearest = Infinity, eligible = 0;
137
+ if (query.velocityTolerance !== undefined)
138
+ for (let i = 0; i < frames.length; i++)
139
+ if (this.allowsFrame(i, query))
140
+ nearest = Math.min(nearest, this.rootVelocityError(i, query));
141
+ const velocityLimit = query.velocityTolerance === undefined ? null : Math.max(query.velocityTolerance, nearest + 1e-6);
142
+ for (let index = 0; index < frames.length; index++) {
143
+ const frame = frames[index], clip = clips[frame.clip];
144
+ let reason;
145
+ if (query.excludedClips?.has(frame.clip))
146
+ reason = 'excluded-clip';
147
+ else if (query.gaits && !query.gaits.has(clip.gait))
148
+ reason = 'gait';
149
+ else if (!matchesContext(clip.context, query.context))
150
+ reason = 'context';
151
+ else if (!withinWindows(frame.time, clip.entryWindows))
152
+ reason = 'entry-window';
153
+ else if (clip.excludedWindows !== undefined && withinWindows(frame.time, clip.excludedWindows))
154
+ reason = 'excluded-window';
155
+ else if (velocityLimit !== null && this.rootVelocityError(index, query) > velocityLimit)
156
+ reason = 'velocity';
157
+ if (reason) {
158
+ rejected[reason]++;
159
+ if (examples.length < limit)
160
+ examples.push({ index, ...frame, reason });
161
+ continue;
162
+ }
163
+ eligible++;
164
+ const cost = this.cost(index);
165
+ if (candidates.length >= limit && cost >= candidates[candidates.length - 1].cost)
166
+ continue;
167
+ const channels = layout.channels.map(({ channel, offset, dimensions }) => {
168
+ let cost = 0;
169
+ for (let j = offset; j < offset + dimensions; j++)
170
+ cost += this.weights[j] * (this.normalized[j] - this.database.features[index * this.dimensions + j]) ** 2;
171
+ return { id: channel.id, cost };
172
+ });
173
+ const candidate = { index, ...frame, cost, channels, velocityError: this.rootOffset === undefined ? null : this.rootVelocityError(index, query) };
174
+ const at = candidates.findIndex(c => c.cost > cost);
175
+ candidates.splice(at < 0 ? candidates.length : at, 0, candidate);
176
+ if (candidates.length > limit)
177
+ candidates.pop();
178
+ }
179
+ return { best, candidates, rejected, examples, eligible, total: frames.length, velocityLimit: Number.isFinite(velocityLimit) ? velocityLimit : null };
180
+ }
97
181
  }
@@ -0,0 +1,24 @@
1
+ /** Exact, consumer-defined semantic values. Missing keys never act as wildcards. */
2
+ export type SelectionContext = Readonly<Record<string, string>>;
3
+ /** Closed intervals, in seconds relative to the cropped playback clip. */
4
+ export type TimeWindow = readonly [number, number];
5
+ export interface SelectionMetadata {
6
+ context?: SelectionContext;
7
+ entryWindows?: readonly TimeWindow[];
8
+ exitWindows?: readonly TimeWindow[];
9
+ excludedWindows?: readonly TimeWindow[];
10
+ }
11
+ export declare function validateContext(value: unknown): asserts value is SelectionContext;
12
+ export declare function matchesContext(metadata: SelectionContext | undefined, requested?: SelectionContext): boolean;
13
+ export declare function withinWindows(time: number, windows: readonly TimeWindow[] | undefined): boolean;
14
+ export declare function validateSelection(value: SelectionMetadata, duration?: number): void;
15
+ import type { Database } from './types.js';
16
+ export interface ContextBank {
17
+ id: string;
18
+ context: SelectionContext;
19
+ database: Database;
20
+ }
21
+ /** Route before searching; costs from independent normalizations are never compared.
22
+ * Caller must use the selected bank's schema, compatible rig/player and matcher.
23
+ */
24
+ export declare function selectContextBank(banks: readonly ContextBank[], context: SelectionContext): ContextBank | null;
@@ -0,0 +1,41 @@
1
+ export function validateContext(value) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value)
3
+ || !Object.entries(value).every(([k, v]) => k.trim().length > 0 && typeof v === 'string' && v.trim().length > 0))
4
+ throw new Error('Invalid selection context');
5
+ }
6
+ export function matchesContext(metadata, requested = {}) {
7
+ return Object.entries(requested).every(([key, value]) => metadata !== undefined && Object.hasOwn(metadata, key) && metadata[key] === value);
8
+ }
9
+ export function withinWindows(time, windows) {
10
+ return windows === undefined || windows.some(([start, end]) => time >= start && time <= end);
11
+ }
12
+ export function validateSelection(value, duration = Infinity) {
13
+ if (value.context !== undefined)
14
+ validateContext(value.context);
15
+ for (const key of ['entryWindows', 'exitWindows', 'excludedWindows']) {
16
+ const windows = value[key];
17
+ if (windows !== undefined && (!Array.isArray(windows) || !windows.every(w => Array.isArray(w) && w.length === 2 && w.every(Number.isFinite)
18
+ && w[0] >= 0 && w[1] >= w[0] && w[1] <= duration)))
19
+ throw new Error('Invalid selection windows: ' + key);
20
+ }
21
+ }
22
+ /** Route before searching; costs from independent normalizations are never compared.
23
+ * Caller must use the selected bank's schema, compatible rig/player and matcher.
24
+ */
25
+ export function selectContextBank(banks, context) {
26
+ validateContext(context);
27
+ const ids = new Set();
28
+ let selected = null;
29
+ for (const bank of banks) {
30
+ if (!bank.id.trim() || ids.has(bank.id))
31
+ throw new Error('Invalid/duplicate context bank id');
32
+ ids.add(bank.id);
33
+ validateContext(bank.context);
34
+ if (!matchesContext(bank.context, context))
35
+ continue;
36
+ if (selected)
37
+ throw new Error('Ambiguous context bank selection');
38
+ selected = bank;
39
+ }
40
+ return selected;
41
+ }