Skip to content

Commit 8444178

Browse files
committed
feat(geometry): add Grassmannian subspace tracking (v4.0)
Introduce Grassmannian manifold operations and middleware for tracking structural alignment of agent behavior over time. This enables comparing the agent's reasoning structure (as a subspace) against a reference trajectory on the Grassmannian Gr(k, d). Core math (src/core/geometry/grassmannian.ts): - extractSubspace: SVD-based subspace extraction from a vector window - principalAngles: canonical angles between two subspaces - geodesicDistance: Riemannian distance on Gr(k, d) - compareSubspaces: full comparison with summary statistics - logMap: tangent vector pointing from current to target subspace - incrementalSubspaceUpdate: O(d·k) rank-1 update via Oja-like rule - subspaceProjectionError: measure novelty outside a subspace Middleware (src/advanced/grassmannian-middleware.ts): - Sliding window of embeddings with configurable windowSize - Subspace extraction via SVD each step - Optional reference trajectory comparison (SubspaceTrajectory interface) - Drift detection with configurable threshold and halt/warn action - Steering direction via log map (optional) - Writes GrassmannianSnapshot to metadata['grassmannian'] Interfaces: - SubspaceBasis: orthonormal basis type (VectorN[]) - SubspaceTrajectory: time-indexed reference for golden arcs - GrassmannianSnapshot: per-step metadata with geodesic distance, principal angles, explained variance, drift status, steering direction Tests: 55 new tests (37 math + 18 middleware), 464 total passing No new dependencies — uses existing ml-matrix SVD.
1 parent eba431a commit 8444178

8 files changed

Lines changed: 1719 additions & 3 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { compareSubspaces, extractSubspace, logMap } from '../core/geometry/grassmannian';
2+
import type { Logger } from '../core/interfaces';
3+
import type { GrassmannianSnapshot, StateEmbedder, SubspaceTrajectory } from '../core/kinematics/interfaces';
4+
import type { VectorN } from '../core/kinematics/types';
5+
import type { Middleware, StepContext, StepResult } from '../core/middleware/types';
6+
7+
export interface GrassmannianMiddlewareOpts<S> {
8+
/** Embedder to convert state → vector. Same as kinematicsMiddleware. */
9+
embedder: StateEmbedder<S>;
10+
/**
11+
* Number of recent embeddings to keep in the sliding window.
12+
* The subspace is extracted from this window each step.
13+
* Larger windows = more stable subspace, slower to react.
14+
* Smaller windows = more responsive, noisier.
15+
* Default: 10.
16+
*/
17+
windowSize?: number;
18+
/**
19+
* Number of principal components (subspace dimension k).
20+
* If omitted, auto-selects to explain ≥ 80% of variance.
21+
*/
22+
subspaceDim?: number;
23+
/**
24+
* Optional reference trajectory for comparison.
25+
* If provided, the middleware compares the current subspace to
26+
* `trajectory.referenceAt(ctx.step)` each step and computes
27+
* geodesic distance, principal angles, and steering direction.
28+
*
29+
* If omitted, the middleware only extracts and reports the current
30+
* subspace (no comparison, no drift detection).
31+
*/
32+
trajectory?: SubspaceTrajectory;
33+
/**
34+
* Geodesic distance threshold for drift detection.
35+
* When the distance to the reference subspace exceeds this value,
36+
* `isDrifting` is set to true.
37+
*
38+
* Only meaningful when `trajectory` is provided.
39+
* If omitted, drift detection is disabled (isDrifting always false).
40+
*/
41+
driftThreshold?: number;
42+
/**
43+
* Action to take when drift is detected.
44+
* - `'warn'` — annotate `isDrifting: true` in metadata, do not halt (default)
45+
* - `'halt'` — return `'halt'` to stop the control loop
46+
*/
47+
driftAction?: 'warn' | 'halt';
48+
/**
49+
* Whether to compute the log map (steering direction) when a reference
50+
* trajectory is provided. The log map gives the tangent vector pointing
51+
* from the current subspace toward the reference.
52+
*
53+
* Default: true (when trajectory is provided).
54+
* Set to false to save computation if you only need distance/angles.
55+
*/
56+
computeSteering?: boolean;
57+
/** Optional logger for Grassmannian telemetry. */
58+
logger?: Logger;
59+
}
60+
61+
/**
62+
* Advanced middleware that performs Grassmannian subspace tracking each step.
63+
*
64+
* It embeds the current state, maintains a sliding window of recent embeddings,
65+
* extracts a subspace via SVD, and optionally compares it to a reference
66+
* trajectory on the Grassmannian manifold.
67+
*
68+
* Writes `ctx.metadata['grassmannian']` with a `GrassmannianSnapshot` containing:
69+
* - Current subspace basis and extraction quality
70+
* - Principal angles and geodesic distance to reference (if trajectory provided)
71+
* - Steering direction via log map (if enabled)
72+
* - Drift detection (if threshold configured)
73+
*
74+
* **Ordering:** Can be stacked independently of `kinematicsMiddleware` and
75+
* `manifoldMiddleware`. They observe different things and write to different
76+
* metadata channels.
77+
*/
78+
export function grassmannianMiddleware<S>(opts: GrassmannianMiddlewareOpts<S>): Middleware<S> {
79+
const windowSize = opts.windowSize ?? 10;
80+
const driftAction = opts.driftAction ?? 'warn';
81+
const computeSteering = opts.computeSteering ?? true;
82+
83+
let window: VectorN[] = [];
84+
85+
return {
86+
name: 'grassmannian',
87+
88+
setup(): Promise<void> {
89+
window = [];
90+
return Promise.resolve();
91+
},
92+
93+
async beforeStep(ctx: StepContext<S>): Promise<StepContext<S> | 'halt'> {
94+
const observation = await opts.embedder.embed(ctx.state);
95+
96+
// Maintain sliding window (FIFO)
97+
window.push(observation);
98+
if (window.length > windowSize) {
99+
window = window.slice(window.length - windowSize);
100+
}
101+
102+
// Need at least 2 vectors to extract a subspace
103+
if (window.length < 2) {
104+
return {
105+
...ctx,
106+
metadata: {
107+
...ctx.metadata,
108+
grassmannian: buildDegenerateSnapshot(observation.length),
109+
},
110+
};
111+
}
112+
113+
// Extract subspace from sliding window via SVD
114+
const extraction = extractSubspace(window, opts.subspaceDim);
115+
116+
if (!extraction || extraction.basis.length === 0) {
117+
return {
118+
...ctx,
119+
metadata: {
120+
...ctx.metadata,
121+
grassmannian: buildDegenerateSnapshot(observation.length),
122+
},
123+
};
124+
}
125+
126+
// Compare to reference trajectory if provided
127+
let comparison = {
128+
principalAngles: [] as number[],
129+
geodesicDistance: 0,
130+
meanAngle: 0,
131+
maxAngle: 0,
132+
};
133+
let steering: VectorN[] | null = null;
134+
let isDrifting = false;
135+
136+
if (opts.trajectory) {
137+
const referenceBasis = opts.trajectory.referenceAt(ctx.step);
138+
139+
if (referenceBasis.length > 0) {
140+
comparison = compareSubspaces(extraction.basis, referenceBasis);
141+
142+
// Compute steering direction (log map)
143+
if (computeSteering) {
144+
steering = logMap(extraction.basis, referenceBasis);
145+
}
146+
147+
// Drift detection
148+
if (opts.driftThreshold != null) {
149+
isDrifting = comparison.geodesicDistance > opts.driftThreshold;
150+
}
151+
}
152+
}
153+
154+
const snapshot: GrassmannianSnapshot = {
155+
currentBasis: extraction.basis,
156+
principalAngles: comparison.principalAngles,
157+
geodesicDistance: comparison.geodesicDistance,
158+
meanAngle: comparison.meanAngle,
159+
maxAngle: comparison.maxAngle,
160+
explainedVariance: extraction.explainedVariance,
161+
windowSize: window.length,
162+
subspaceDim: extraction.subspaceDim,
163+
isDrifting,
164+
steeringDirection: steering,
165+
};
166+
167+
if (opts.logger) {
168+
opts.logger.info(
169+
{
170+
grassmannian: {
171+
step: ctx.step,
172+
geodesicDistance: parseFloat(comparison.geodesicDistance.toFixed(4)),
173+
meanAngle: parseFloat(comparison.meanAngle.toFixed(4)),
174+
maxAngle: parseFloat(comparison.maxAngle.toFixed(4)),
175+
explainedVariance: parseFloat(extraction.explainedVariance.toFixed(4)),
176+
windowSize: window.length,
177+
subspaceDim: extraction.subspaceDim,
178+
isDrifting,
179+
},
180+
},
181+
`[Grassmannian] Step ${ctx.step}: d_Gr=${comparison.geodesicDistance.toFixed(4)}, meanθ=${comparison.meanAngle.toFixed(4)}, maxθ=${comparison.maxAngle.toFixed(4)}, k=${extraction.subspaceDim}, Drifting=${isDrifting}`,
182+
);
183+
}
184+
185+
// Halt if drift detected and action is 'halt'
186+
if (isDrifting && driftAction === 'halt') {
187+
return 'halt';
188+
}
189+
190+
return {
191+
...ctx,
192+
metadata: { ...ctx.metadata, grassmannian: snapshot },
193+
};
194+
},
195+
196+
afterStep(_ctx: StepContext<S>, _result: StepResult<S>): Promise<void> {
197+
return Promise.resolve();
198+
},
199+
};
200+
}
201+
202+
/**
203+
* Build a GrassmannianSnapshot for degenerate cases (not enough vectors in window).
204+
*/
205+
function buildDegenerateSnapshot(ambientDim: number): GrassmannianSnapshot {
206+
return {
207+
currentBasis: [],
208+
principalAngles: [],
209+
geodesicDistance: 0,
210+
meanAngle: 0,
211+
maxAngle: 0,
212+
explainedVariance: 0,
213+
windowSize: ambientDim > 0 ? 1 : 0,
214+
subspaceDim: 0,
215+
isDrifting: false,
216+
steeringDirection: null,
217+
};
218+
}

src/advanced/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export type { GrassmannianMiddlewareOpts } from './grassmannian-middleware';
2+
export { grassmannianMiddleware } from './grassmannian-middleware';
13
export type { CorrectionInfo, KinematicsMiddlewareOpts, KinematicsSnapshot } from './kinematics-middleware';
24
export { kinematicsMiddleware } from './kinematics-middleware';
35
export type { ManifoldMiddlewareOpts } from './manifold-middleware';

0 commit comments

Comments
 (0)