Skip to content

Commit eba431a

Browse files
committed
feat(geometry): add Riemannian manifold control (v3.0)
Introduce manifold-aware control layers that detect off-manifold drift using local PCA on k-nearest neighbors from a corpus embedding store. Core math (geometry/manifold.ts): - localPCA via Gramian dual trick (O(k³) not O(d³)) - Tangent/normal decomposition, curvature estimation, autoTopK - Distance to nearest neighbor and centroid manifoldMiddleware: - Annotates metadata['manifold'] with ManifoldSnapshot each step - Reads EKF-filtered velocity from kinematicsMiddleware when stacked - Configurable drift detection (driftThreshold + driftAction: warn|halt) - Falls back to raw velocity when kinematics middleware absent kinematicsMiddleware (extended): - Optional `manifold` config enables v_normal as PID error signal - PID corrects only for off-manifold drift, not on-manifold exploration - Fully backward compatible without manifold option New interfaces: - ManifoldProvider — knn() for corpus geometry queries - ManifoldSnapshot — velocity decomposition, curvature, drift status - LocalGeometry — tangent/normal basis, eigenvalues Tests: 409 passing (+59 new) across 31 files, zero regressions.
1 parent fe1b5ef commit eba431a

13 files changed

Lines changed: 1541 additions & 6 deletions

File tree

src/advanced/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export type { CorrectionInfo, KinematicsMiddlewareOpts, KinematicsSnapshot } from './kinematics-middleware';
22
export { kinematicsMiddleware } from './kinematics-middleware';
3+
export type { ManifoldMiddlewareOpts } from './manifold-middleware';
4+
export { manifoldMiddleware } from './manifold-middleware';

src/advanced/kinematics-middleware.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { decompose, localPCA } from '../core/geometry/manifold';
12
import type { Logger } from '../core/interfaces';
23
import { PhysicsEngine } from '../core/kinematics/engine';
3-
import type { StateEmbedder } from '../core/kinematics/interfaces';
4+
import type { ManifoldProvider, StateEmbedder } from '../core/kinematics/interfaces';
45
import { norm } from '../core/kinematics/math';
56
import { PIDController } from '../core/kinematics/pid';
67
import type { KinematicState, VectorN } from '../core/kinematics/types';
@@ -46,6 +47,23 @@ export interface KinematicsMiddlewareOpts<S> {
4647
processNoise?: number;
4748
measureNoise?: number;
4849
};
50+
/**
51+
* v3.0: Optional corpus geometry provider for manifold-aware control.
52+
*
53+
* When provided, the PID controller uses the **normal component** of the
54+
* EKF velocity (v_normal — off-manifold drift) as its error signal instead
55+
* of the raw physics error. This means the controller only corrects for
56+
* drift off the data manifold, not for valid on-manifold exploration.
57+
*
58+
* Requires the same `ManifoldProvider` used by `manifoldMiddleware`.
59+
*/
60+
manifold?: {
61+
provider: ManifoldProvider;
62+
/** Number of neighbors for local PCA. Default: 50. */
63+
k?: number;
64+
/** Number of principal components for tangent space. Default: auto (80% variance). */
65+
topK?: number;
66+
};
4967
/** Optional logger for kinematics telemetry. */
5068
logger?: Logger;
5169
}
@@ -120,18 +138,37 @@ export function kinematicsMiddleware<S>(opts: KinematicsMiddlewareOpts<S>): Midd
120138
}
121139

122140
// Update physics
123-
const { next, error, coherence } = engine.update(lastPhysicsState, observation, origin!);
141+
const { next, error: rawError, coherence } = engine.update(lastPhysicsState, observation, origin!);
142+
143+
// v3.0: If manifold provider is configured, use v_normal as PID error
144+
// instead of raw physics error. This makes the controller correct only
145+
// for off-manifold drift, not valid on-manifold exploration.
146+
let pidError = rawError;
147+
if (opts.manifold) {
148+
// PERF: k-NN query is the bottleneck here, same as in manifoldMiddleware.
149+
// If both middlewares are stacked, this is a redundant query.
150+
// Future optimization: share geometry via metadata.
151+
const manifoldK = opts.manifold.k ?? 50;
152+
const neighbors = await opts.manifold.provider.knn(observation, manifoldK);
153+
if (neighbors.length >= 2) {
154+
const geometry = localPCA(neighbors, opts.manifold.topK);
155+
if (geometry.tangentBasis.length > 0) {
156+
const { normal } = decompose(next.velocity, geometry.tangentBasis);
157+
pidError = normal;
158+
}
159+
}
160+
}
124161

125162
// Compute PID correction
126-
const correction = pid.compute(error);
163+
const correction = pid.compute(pidError);
127164

128165
const angleDeg = coherence * 180 / Math.PI;
129166

130167
const snapshot: KinematicsSnapshot = {
131168
position: next.position,
132169
velocity: next.velocity,
133-
error,
134-
errorMagnitude: norm(error),
170+
error: pidError,
171+
errorMagnitude: norm(pidError),
135172
correctionMagnitude: correction.magnitude,
136173
coherenceAngleDeg: angleDeg,
137174
isStable: correction.isStable,
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { decompose, distanceToCentroid, localPCA } from '../core/geometry/manifold';
2+
import type { Logger } from '../core/interfaces';
3+
import type { ManifoldProvider, ManifoldSnapshot, StateEmbedder } from '../core/kinematics/interfaces';
4+
import { norm, subtract } from '../core/kinematics/math';
5+
import type { VectorN } from '../core/kinematics/types';
6+
import type { Middleware, StepContext, StepResult } from '../core/middleware/types';
7+
import type { KinematicsSnapshot } from './kinematics-middleware';
8+
9+
export interface ManifoldMiddlewareOpts<S> {
10+
/** Embedder to convert state → vector. */
11+
embedder: StateEmbedder<S>;
12+
/** Corpus geometry provider (vector DB, embedding store, etc.). */
13+
manifold: ManifoldProvider;
14+
/** Number of neighbors for local PCA. Default: 50. */
15+
k?: number;
16+
/**
17+
* Number of principal components for tangent space.
18+
* If omitted, auto-selects to explain 80% of variance.
19+
*/
20+
topK?: number;
21+
/**
22+
* Distance threshold for drift detection. When the nearest neighbor
23+
* distance exceeds this value, the agent is considered to be drifting
24+
* off the manifold (in a "data desert").
25+
*
26+
* If omitted, drift detection is disabled (isDrifting always false).
27+
*/
28+
driftThreshold?: number;
29+
/**
30+
* Action to take when drift is detected.
31+
* - `'warn'` — annotate `isDrifting: true` in metadata, do not halt (default)
32+
* - `'halt'` — return `'halt'` to stop the control loop
33+
*/
34+
driftAction?: 'warn' | 'halt';
35+
/** Optional logger for manifold telemetry. */
36+
logger?: Logger;
37+
}
38+
39+
/**
40+
* Advanced middleware that performs Riemannian manifold analysis each step.
41+
*
42+
* It embeds the current state, queries the ManifoldProvider for k nearest
43+
* neighbors, runs local PCA to compute the tangent/normal decomposition,
44+
* and annotates `ctx.metadata['manifold']` with a `ManifoldSnapshot`.
45+
*
46+
* **Velocity source:** If `kinematicsMiddleware` is stacked before this
47+
* middleware, the EKF-filtered velocity from `metadata['kinematics']` is
48+
* used. Otherwise, raw velocity is computed as (current - previous embedding).
49+
*
50+
* The middleware **observes and annotates** by default. When `driftThreshold`
51+
* is set, it can also detect when the agent enters a "data desert" (no nearby
52+
* corpus data). The `driftAction` option controls whether this triggers a
53+
* halt or just a warning annotation in metadata.
54+
*
55+
* **Ordering:** Stack this middleware AFTER `kinematicsMiddleware` so that
56+
* the filtered velocity is available.
57+
*/
58+
export function manifoldMiddleware<S>(opts: ManifoldMiddlewareOpts<S>): Middleware<S> {
59+
const k = opts.k ?? 50;
60+
const driftAction = opts.driftAction ?? 'warn';
61+
62+
let previousEmbedding: VectorN | null = null;
63+
64+
return {
65+
name: 'manifold',
66+
67+
setup(): Promise<void> {
68+
previousEmbedding = null;
69+
return Promise.resolve();
70+
},
71+
72+
async beforeStep(ctx: StepContext<S>): Promise<StepContext<S>> {
73+
const observation = await opts.embedder.embed(ctx.state);
74+
75+
// PERF: The k-NN query is likely the dominant cost in this middleware.
76+
// Future work: add caching if position hasn't moved significantly,
77+
// or accept a timeout option on ManifoldProvider.
78+
const neighbors = await opts.manifold.knn(observation, k);
79+
80+
// Degenerate case: not enough neighbors for meaningful PCA
81+
if (neighbors.length < 2) {
82+
previousEmbedding = observation;
83+
return {
84+
...ctx,
85+
metadata: {
86+
...ctx.metadata,
87+
manifold: buildDegenerateSnapshot(observation, neighbors.length),
88+
},
89+
};
90+
}
91+
92+
// Run local PCA (Gramian dual trick — O(k³) not O(d³))
93+
const geometry = localPCA(neighbors, opts.topK);
94+
95+
// Resolve velocity: prefer EKF-filtered from kinematics middleware
96+
const velocity = resolveVelocity(ctx, observation, previousEmbedding);
97+
98+
// Decompose velocity into tangent (on-manifold) and normal (drift)
99+
const { tangent, normal } = geometry.tangentBasis.length > 0
100+
? decompose(velocity, geometry.tangentBasis)
101+
: { tangent: velocity.map(() => 0), normal: velocity };
102+
103+
// Compute distance to nearest neighbor
104+
const nearestDist = nearestNeighborDistance(observation, neighbors);
105+
106+
// Determine if agent is drifting off manifold
107+
const isDrifting = opts.driftThreshold != null && nearestDist > opts.driftThreshold;
108+
109+
const snapshot: ManifoldSnapshot = {
110+
velocityTangent: tangent,
111+
velocityNormal: normal,
112+
normalDriftMagnitude: norm(normal),
113+
curvature: geometry.curvature,
114+
explainedVariance: geometry.explainedVariance,
115+
distanceToCentroid: distanceToCentroid(observation, geometry.centroid),
116+
distanceToNearestNeighbor: nearestDist,
117+
neighborCount: neighbors.length,
118+
isDrifting,
119+
};
120+
121+
previousEmbedding = observation;
122+
123+
if (opts.logger) {
124+
opts.logger.info(
125+
{
126+
manifold: {
127+
step: ctx.step,
128+
curvature: parseFloat(geometry.curvature.toFixed(4)),
129+
explainedVariance: parseFloat(geometry.explainedVariance.toFixed(4)),
130+
normalDrift: parseFloat(snapshot.normalDriftMagnitude.toFixed(4)),
131+
nearestNeighbor: parseFloat(nearestDist.toFixed(4)),
132+
neighbors: neighbors.length,
133+
isDrifting,
134+
},
135+
},
136+
`[Manifold] Step ${ctx.step}: κ=${geometry.curvature.toFixed(4)}, Drift=${snapshot.normalDriftMagnitude.toFixed(4)}, NearestNeighbor=${nearestDist.toFixed(4)}, Drifting=${isDrifting}`,
137+
);
138+
}
139+
140+
// Halt if drift detected and action is 'halt'
141+
if (isDrifting && driftAction === 'halt') {
142+
return 'halt' as unknown as StepContext<S>;
143+
}
144+
145+
return {
146+
...ctx,
147+
metadata: { ...ctx.metadata, manifold: snapshot },
148+
};
149+
},
150+
151+
afterStep(_ctx: StepContext<S>, _result: StepResult<S>): Promise<void> {
152+
return Promise.resolve();
153+
},
154+
};
155+
}
156+
157+
/**
158+
* Resolve the velocity vector for manifold decomposition.
159+
*
160+
* Prefers EKF-filtered velocity from kinematicsMiddleware (metadata['kinematics']).
161+
* Falls back to raw velocity (current - previous embedding) if kinematics
162+
* middleware is not present or this is the first step.
163+
*/
164+
function resolveVelocity<S>(
165+
ctx: StepContext<S>,
166+
currentEmbedding: VectorN,
167+
previousEmbedding: VectorN | null,
168+
): VectorN {
169+
// Try to read EKF-filtered velocity from kinematics middleware
170+
const kinematicsData = ctx.metadata['kinematics'] as KinematicsSnapshot | undefined;
171+
if (kinematicsData?.velocity) {
172+
return kinematicsData.velocity;
173+
}
174+
175+
// Fallback: raw velocity from consecutive embeddings
176+
if (previousEmbedding) {
177+
return subtract(currentEmbedding, previousEmbedding);
178+
}
179+
180+
// First step: no velocity available
181+
return currentEmbedding.map(() => 0);
182+
}
183+
184+
/**
185+
* Build a ManifoldSnapshot for degenerate cases (too few neighbors).
186+
*/
187+
function buildDegenerateSnapshot(observation: VectorN, neighborCount: number): ManifoldSnapshot {
188+
const d = observation.length;
189+
return {
190+
velocityTangent: new Array<number>(d).fill(0),
191+
velocityNormal: new Array<number>(d).fill(0),
192+
normalDriftMagnitude: 0,
193+
curvature: 1, // maximally uncertain
194+
explainedVariance: 0,
195+
distanceToCentroid: 0,
196+
distanceToNearestNeighbor: Infinity,
197+
neighborCount,
198+
isDrifting: true, // no neighbors = definitely drifting
199+
};
200+
}
201+
202+
/**
203+
* Compute the Euclidean distance from a point to its nearest neighbor.
204+
*/
205+
function nearestNeighborDistance(point: VectorN, neighbors: VectorN[]): number {
206+
let minDist = Infinity;
207+
for (const neighbor of neighbors) {
208+
const d = norm(subtract(point, neighbor));
209+
if (d < minDist) minDist = d;
210+
}
211+
return minDist;
212+
}

src/core/geometry/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@
22
* Geometry module — pure math operations for CyberLoop's control layers.
33
*
44
* - `vector.ts` — Euclidean operations (v2.1+)
5-
* - `manifold.ts` — Riemannian PCA, curvature, tangent plane (v3.0, future)
5+
* - `manifold.ts` — Riemannian PCA, curvature, tangent plane (v3.0)
66
* - `grassmannian.ts` — SVD, principal angles, geodesic distance (v4.0, future)
77
*/
8+
export type { LocalGeometry } from './manifold';
9+
export {
10+
autoTopK,
11+
centroid,
12+
curvature,
13+
decompose,
14+
distanceToCentroid,
15+
localPCA,
16+
projectOnto,
17+
} from './manifold';
818
export {
919
add,
1020
angleBetween,

0 commit comments

Comments
 (0)