Echelon Layer 4 — Body-Time Loop Architecture
The CLAUDE.md description of the 128D layout is **partially wrong**. The authoritative source is `cc-brain/src/san/mod.rs:flatten_latent()` (lines 52–98). The actual Rust-side layout:
Full Public Reader
# Echelon Layer 4 — Body-Time Loop Architecture
## Crucible Stage 3: FORGE
Subject: Body → Echelon → Music Closed Loop with 128D Temporal Closure
Date: 2026-05-08
Status: FORGE PASS — ready for implementation review
---
GROUND TRUTH CORRECTION (read before implementing)
The CLAUDE.md description of the 128D layout is partially wrong. The authoritative source is `cc-brain/src/san/mod.rs:flatten_latent()` (lines 52–98). The actual Rust-side layout:
[0:32] z vector (16D LIM-RPS latent, zero-padded) flatten_latent()
[32:64] velocity (16D, zero-padded) flatten_latent()
[64] norm flatten_latent()
[65] speed flatten_latent()
[66] curvature flatten_latent()
[67] curvature_rate flatten_latent()
[68] jerk flatten_latent()
[69] internal_tempo / 200.0 <-- TEMPO IS HERE flatten_latent()
[70] phase <-- PHASE IS HERE flatten_latent()
[71] periodicity <-- PERIODICITY HERE flatten_latent()
[72] grounding flatten_latent()
[73] verticality flatten_latent()
[74] rotation flatten_latent()
[75] coherence flatten_latent()
[76:100] Mocopi 24D filled by Swift getDynamics128()
[100:102] Pocket IMU (pitch, roll) filled by Swift
[102:104] Watch (HR, wrist energy) filled by Swift
[104:128] reserved (zeros)Consequence: temporal data (tempo, phase, periodicity) ALREADY EXISTS in [69:72]. The gap is not that the data is absent — it is that `internal_tempo` is computed in `SimpleLatentUpdater.compute_dynamics()` via `detect_periodicity()` on norm history (autocorrelation of latent-space norm), NOT from a real autocorrelation of body periodicity. This estimate is noisy, has no confidence signal, and is not exposed back to iOS in a queryable way.
The TEMPORAL SLOT PROBLEM is thus:
1. `internal_tempo` uses a proxy (norm variance) not body-kinematics autocorrelation
2. Tempo confidence [70] is currently repurposed as `phase` (not a confidence value)
3. No "body phase within current cycle" (normalized 0-1 position) distinct from `phase`
4. No swing amount in the canonical vector at all
5. No API to push an external tempo estimate (from Mocopi, audio analysis, tap tempo) into the Rust brain
---
1. SYSTEM DIAGRAM
60 Hz FRAME LOOP
─────────────────
iPhone Sensors iOS process
┌─────────────────────────────────────────────────────────┐
│ CMMotion Vision Body Pose Mocopi (BLE) │
│ accel/gyro/quat 14 joints × (x,y,c) 27 bones │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ EchelonBridge.updateSensor() updatePose() mocopiExtractor
│ │ │
│ │ queued SensorFrameFFI[] │
│ ▼ │
│ EchelonBridge.step(dt) [60 Hz, @MainActor] │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RUST FFI BOUNDARY │ │
│ │ │ │
│ │ echelon_update_sensor() x N │ │
│ │ echelon_step(dt) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ EchelonCore.step() │ │
│ │ SimpleLatentUpdater / DellLatentUpdater │ │
│ │ → LatentState {z[16], velocity[16], │ │
│ │ internal_tempo, phase, periodicity, ...} │ │
│ │ → WindowAligner → AnticipationKernel │ │
│ │ → SectionStateMachine │ │
│ │ → LexiconController │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ echelon_get_dynamics_128() │ │
│ │ flatten_latent() → 128D canonical │ │
│ │ [69]=tempo/200 [70]=phase [71]=periodicity │ │
│ │ │ │ │
│ └─────────┼────────────────────────────────────────┘ │
│ │ │
│ ▼ Swift fills [63:69] [75:104] │
│ EchelonBridge.getDynamics128() │
│ buf[63:69] ← cachedPoseFeatures / mocopiExtractor │
│ buf[75] ← modality mask / 15.0 │
│ buf[76:100] ← mocopiExtractor.features24D │
│ buf[100:104]← pocketIMU + watchFeatures │
│ buf[69:72] ← ALREADY SET by Rust (tempo/phase/per) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ TEMPORAL SLOT POPULATION POINT │ │
│ │ Line ~349-382 of EchelonBridge.swift │ │
│ │ After: echelon_get_dynamics_128() returns │ │
│ │ Before: getDynamics128() returns to caller │ │
│ │ │ │
│ │ NEW: buf[69] ← tempoEstimator.bpm / 200.0 │ │
│ │ NEW: buf[70] ← tempoEstimator.confidence │ │
│ │ NEW: buf[71] ← bodyPhaseTracker.phase │ │
│ │ NEW: buf[72] ← phraseTracker.phrasePosition │ │
│ │ NEW: buf[73] ← tempoEstimator.swing │ │
│ │ (grounding moves to [74], rest shuffle 1 slot) │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ [Float] 128D │
│ MotionMixApp.swift:1063 │
│ hub.sendBarComplete(dynamics:...) │
│ │ │
│ ┌─────────┴──────────────────────────────────────┐ │
│ │ DIFFUSION SERVICE PRIORITY CHAIN │ │
│ │ │ │
│ │ 1. Hub (connected && confidence > 0.5) │ │
│ │ PhoneHubClient.latestPredictedGrid │ │
│ │ │ │ │
│ │ 2. CoreML (ConditioningEncoder + Flow) │ │
│ │ dynamics[0:104] → encoder → 768D embed │ │
│ │ TTTAdaptationLayer.adapt(embed) │ │
│ │ FlowGenerator1Step(noise, embed) → logits │ │
│ │ → 12×32 TokenGrid │ │
│ │ │ │ │
│ │ 3. Rule-based fallback │ │
│ │ energy/tension/genre → hardcoded patterns │ │
│ │ │ │
│ │ DECISION: Hub check at DiffusionService.generate() │
│ │ line 131-138 (generate(latent:modelOutput:)) │ │
│ └─────────┬──────────────────────────────────────┘ │
│ │ TokenGrid (12×32) │
│ ▼ │
│ DiffusionService.applyToEngine(engine, grid) │
│ AudioEngine.crossfadePattern() │
│ │ │
│ ▼ │
│ StrudelEngine / AVAudio → audio output │
│ │
│ ALSO: SANService.step(core: b) 30Hz cadence │
│ san_step → SANPipeline │
│ → FAN → FuseMoE → NHA → TTT → FiLM heads │
│ → SANOutputFFI {phrase_forming, phrase_growing, │
│ phrase_stable, phrase_dissolving, │
│ mix_factor, bar_boundary, ...} │
│ SAN output.mix_factor gates heuristic↔SAN blend │
└─────────────────────────────────────────────────────────┘---
2. COMPONENT CONTRACTS
cc-brain Rust crate (EchelonCore)
What changes:
A. Add `echelon_push_temporal()` FFI function — external tempo injection.
// ffi.rs addition
/// Push an externally computed tempo estimate into the latent updater.
/// This overrides SimpleLatentUpdater's autocorrelation estimate for one frame.
/// confidence: 0.0-1.0 (0 = ignore, use autocorrelation; 1 = fully trust external)
/// bpm: 30.0-200.0
/// body_phase: 0.0-1.0 (position in current body cycle)
/// swing: -0.5-0.5 (negative = laid back, positive = ahead)
///
/// Safety: core must be a valid pointer.
#[no_mangle]
pub unsafe extern "C" fn echelon_push_temporal(
core: EchelonHandle,
bpm: f32,
confidence: f32,
body_phase: f32,
swing: f32,
) {
if core.is_null() { return; }
let core = &mut *core;
core.push_temporal(bpm, confidence, body_phase, swing);
}B. Add `push_temporal()` method on `EchelonCore` — passes through to LatentUpdater.
// lib.rs addition
pub fn push_temporal(&mut self, bpm: f32, confidence: f32, body_phase: f32, swing: f32) {
self.latent_updater.push_temporal(bpm, confidence, body_phase, swing);
}C. Add `push_temporal()` to `LatentUpdater` trait with a default no-op.
// latent/mod.rs — add to trait
fn push_temporal(&mut self, _bpm: f32, _confidence: f32, _body_phase: f32, _swing: f32) {
// Default: no-op (SimpleLatentUpdater uses its own autocorrelation)
}D. Implement in `SimpleLatentUpdater`:
// latent/simple.rs — new field + impl
struct TemporalOverride {
bpm: f32,
confidence: f32,
body_phase: f32,
swing: f32,
frames_remaining: u8, // decay over 6 frames (~100ms at 60Hz)
}
// In compute_dynamics(), after autocorrelation block:
if let Some(ref ov) = self.temporal_override {
let w = ov.confidence;
self.state.internal_tempo = w * ov.bpm + (1.0 - w) * self.state.internal_tempo;
self.state.phase = ov.body_phase; // body phase overrides phase
}E. `flatten_latent()` in `san/mod.rs` — EXTEND layout to carry swing and confidence.
The current layout puts `coherence` at [75] but the Swift layer interprets [75] as the modality mask. This is the existing mismatch (Rust writes coherence, Swift overwrites it with the modality mask). This mismatch must be documented and resolved in the temporal extension. See Section 4 for the resolved layout.
What does NOT change:
- `DELLCoordinator` — its `beat_phase` and its frame_count-based metronome are internal; the external push_temporal() provides body-derived tempo without touching DELL internals.
- `SANPipeline.step()` — reads latent directly; temporal slots in the flat vector feed through automatically.
- All existing FFI functions.
---
motion-bridge Rust crate
What changes: `MotionFrame.tempo_bpm` and `MotionFrame.beat_phase` already exist (lib.rs lines 166-170). The missing link is that these values from `Episode1Features` (which carries Mocopi/watch-derived tempo) never reach `EchelonCore`.
Pipeline addition:
- `ModulationRouter` should read `MotionFrame.tempo_bpm` and `MotionFrame.beat_phase`
- Route these into an `echelon_push_temporal()` call on each `BeatSync` event
- This is the "Mocopi as authoritative clock" path when Mocopi is present
Concrete change: In `modulation_router.rs`, when a `MotionEvent::BeatSync` fires:
MotionEvent::BeatSync { phase, tempo, .. } => {
// NEW: push to brain if connected
if let Some(brain) = self.brain_handle {
unsafe { echelon_push_temporal(brain, tempo, 0.85, phase, 0.0); }
}
}What does NOT change:
- The HTTP server for Sensor Logger integration
- `EnsembleCoordinator` sync logic
- `MocopiClient` frame parsing
---
iOS EchelonBridge.swift
What changes:
A. Expose `pushTemporal()` as a Swift method:
// New method in EchelonBridge.swift
func pushTemporal(bpm: Float, confidence: Float, bodyPhase: Float, swing: Float) {
guard isEnabled, let b = brain else { return }
echelon_push_temporal(b, bpm, confidence, bodyPhase, swing)
}
// Feature flag (starts false, flip to true after validation)
var temporalSlotEnabled: Bool = falseB. Modify `getDynamics128()` to overlay temporal slots AFTER Rust fills them, but only when temporalSlotEnabled and a fresh estimate exists:
// In getDynamics128(), after the Rust echelon_get_dynamics_128() call:
if temporalSlotEnabled, let ts = temporalStateCache {
// Resolved 128D temporal layout — see Section 4
buf[69] = ts.bpm / 200.0 // tempo estimate
buf[70] = ts.confidence // tempo confidence
buf[71] = ts.bodyPhase // phase in body cycle
buf[72] = ts.phrasePosition // phrase position
buf[73] = (ts.swing + 0.5) // swing, mapped [0,1]
// buf[74:76] — grounding/coherence from Rust — leave untouched
}C. `temporalStateCache` is updated by:
- `SANService.output.phase` (NHA learned phase, most accurate when trained)
- `TempoEstimator` class (new lightweight class, autocorrelation of hip Y history from pose metrics)
- `pushTemporal()` from Mocopi-derived beats (when motion-bridge fires BeatSync)
D. Add logging:
// Every 60 frames, log temporal slots for validation
if bridgeStepCount % 60 == 0, temporalSlotEnabled {
let dyn = getDynamics128() ?? []
let tempo = dyn.count > 69 ? dyn[69] * 200.0 : 0
let conf = dyn.count > 70 ? dyn[70] : 0
let phase = dyn.count > 71 ? dyn[71] : 0
print("[EchelonBridge][T] tempo=\(String(format:"%.1f",tempo)) conf=\(String(format:"%.2f",conf)) phase=\(String(format:"%.3f",phase))")
}What does NOT change:
- The `shouldFireBar()` mechanism — it still uses `echelon_get_bar_trigger()` which reads `latent.phase` and `latent.periodicity`.
- The `getDynamics128()` fallback path — heuristic branch unchanged.
- All sensor update paths.
---
DiffusionService.swift
What changes: None to the priority chain logic. The temporal slots ([69:74]) will now carry meaningful values when populated by `EchelonBridge`. The ConditioningEncoder consumes dynamics[0:104]; since [69:73] are within that range, the CoreML model will automatically receive the enriched temporal data once V6 is retrained with temporal labels.
Existing shim (lines 258-266) remains intact — truncating 128D to 104D is correct while ConditioningEncoder is still V5.
What does NOT change:
- Hub > CoreML > Rules priority chain
- `generateWithCoreML()` function signature
- `TTTAdaptationLayer` adaptation cadence
- `onBarBoundary()` path
- `buildDynamicsVector()` — it calls `echelonBridge.getDynamics128()` which will transparently return the temporally-enriched vector once EchelonBridge.temporalSlotEnabled = true
---
SANService.swift
What changes: None. SANService reads latent directly through `san_step(san, core)` which calls `core.latent()`. The temporal push through `echelon_push_temporal()` mutates `LatentState.internal_tempo` and `LatentState.phase` inside the Rust brain — SANService will automatically see the updated values on the next `san_step()` call.
The phrase lifecycle (`phrase_forming`, `phrase_growing`, `phrase_stable`, `phrase_dissolving`) is computed by the SAN NHA from the latent dynamics. When body-derived tempo improves phase tracking, NHA will produce more coherent phrase boundaries. No API changes required.
`mixFactor` blending (`blend(_ sanValue: Float, heuristic: Float)`) remains completely untouched.
---
3. DATA FLOW SEQUENCE (per frame, 60 Hz)
Frame N:
1. [CMMotion thread] CoreMotion delivers acceleration/gyro/attitude
→ EchelonBridge.updateSensor()
→ SensorFrameFFI appended to pendingSensorFrames[]
2. [MainActor, CADisplayLink] EchelonBridge.step(dt: 1/60)
a. Lock/drain pendingSensorFrames
b. For each frame: echelon_update_sensor(brain, &frame)
c. echelon_step(brain, dt)
→ SimpleLatentUpdater.update(&frame, dt)
→ extract_features: accel(3)+gyro(3)+gravity(3)+orientation(4) = 13D
→ normalize (running mean/var EMA, warmup=130 frames)
→ linear_project (random orthogonal 13→16)
→ z_ema.update() [alpha=0.85 default]
→ compute_dynamics():
velocity ← (z - prev_z) / dt, smoothed by velocity_ema
norm_history.push(l2_norm(z))
detect_periodicity(norm_history) → (strength, period_frames)
internal_tempo ← 60.0 / (period_frames / 100.0) [approx 100Hz]
phase ← advances by dt/beat_period each frame, wraps at 1.0
→ update_somatic(): grounding/verticality/rotation from accel
→ SectionStateMachine.update(latent, dt) → SectionState
→ LexiconController.update(latent, state, ...) → Lexicon
IF temporalSlotEnabled AND Mocopi/audio tempo available:
c'. echelon_push_temporal(brain, bpm, confidence, bodyPhase, swing)
→ SimpleLatentUpdater.push_temporal()
→ weighted blend: internal_tempo = conf*bpm + (1-conf)*internal_tempo
→ phase = bodyPhase (body phase overrides free-running phase)
→ stores TemporalOverride for N frames
3. [MainActor] echelon_bridge_brain_to_audio(brain, audio) — Echelon synth params
4. [MainActor] Read UI outputs: echelon_get_latent(), echelon_get_lexicon(), etc.
5. [MainActor] san.step(core: brain)
→ san_step(sanHandle, brain)
→ SANPipeline.step(latent)
→ flatten_latent(latent, 128) — reads internal_tempo/phase/periodicity
→ FAN: per-feature calibration (running stats)
→ FuseMoE: 6 experts, top-2 gating, 128→40→128 per expert
→ NHA: ODE state step, learned phase output
→ TTT: session-adapted fast weights
→ FiLM heads: audio/camera/phrase/gesture outputs
→ san_get_output() → SANOutputFFI
.phrase_forming/growing/stable/dissolving
.mix_factor (gates heuristic↔SAN blend)
.bar_boundary (from NHA learned phase)
.phase
6. [MainActor] Claim detection via ClaimBridgeService
→ echelon_get_dynamics_128() → 128D canonical
→ claim_bridge_detect() → ClaimDetection[]
7. [MainActor, bar boundary event ~every 0.5s at 120BPM]
MotionMixApp.swift:1057-1075
a. echelonBridge.shouldFireBar() = echelon_get_bar_trigger()
→ checks: latent.phase < 0.05 && latent.periodicity > 0.3
b. echelonBridge.getDynamics128() → [Float] 128D
→ echelon_get_dynamics_128(): flatten_latent(latent, 128)
→ Swift overwrites:
buf[63:69] ← cachedPoseFeatures or mocopiExtractor.poseFeatures6D
buf[75] ← modality mask / 15.0
buf[76:100]← mocopiExtractor.features24D
buf[100:104]← pocketIMU + watchFeatures
IF temporalSlotEnabled:
buf[69:74] ← temporalStateCache (bpm, conf, bodyPhase, phrasePos, swing)
c. MotionMixApp writes activations [39:51] + expressions [51:63] directly
d. hub.sendBarComplete(dynamics: dynamics, ...)
8. [MainActor, at bar boundary] DiffusionService.generate()
a. Priority check: hubClient.isConnected && hub.confidence >= 0.5
→ return hub.latestPredictedGrid (source = .hub)
b. generateWithCoreML(dynamics):
→ TEMPORARY truncation: dynamics[0:104] → MLMultiArray(shape:[1,104])
→ ConditioningEncoder.prediction() → baseConditioning (768D)
→ TTTAdaptationLayer.adapt(baseEmbedding) → adaptedEmbedding
→ Gaussian noise (1,384,81) → FlowGenerator1Step → logits
→ argmax per position / temperature → TokenGrid (source = .coreml)
c. generateRuleBased() (source = .rules)
9. [MainActor] DiffusionService.applyToEngine(engine, grid)
→ AudioEngine.crossfadePattern(kick, clap, hat, bassNotes)
→ AVAudio sample players update on next buffer
10. [Audio render thread] EchelonBridge.renderAudio(buffer, frames, channels)
→ echelon_render_audio(audio, ...) — Echelon synthesis (MotionSynth)
→ Samples written to CoreAudio ring buffer
11. [CoreAudio thread] AVAudioSourceNode callback
→ StrudelEngine / AudioEngine pattern voices mixed
→ Output to speakers/Bluetooth
Total latency from body movement to audio change: ~50-100ms nominal
With temporal slot population: adds ~0ms (in-path, no extra allocation)---
4. THE ACTUAL 128D LAYOUT
Based on code audit of `san/mod.rs:flatten_latent()` (authoritative) + `EchelonBridge.getDynamics128()` (Swift layer):
Rust output from echelon_get_dynamics_128() — before Swift overlay
[0:16] z[0:16] LIM-RPS latent (16 dims) LatentState.z
[16:32] zeros (z padded to 32)
[32:48] vel[0:16] latent velocity (16 dims) LatentState.velocity
[48:64] zeros (velocity padded to 32)
[64] norm l2_norm(z)
[65] speed l2_norm(velocity)
[66] curvature curvature_from_history()
[67] curvature_rate
[68] jerk curvature_rate * speed
[69] tempo_n internal_tempo / 200.0 (0=30BPM, 0.6=120BPM, 1.0=200BPM)
[70] phase 0.0-1.0, free-running from tempo
[71] periodicity 0.0-1.0, autocorrelation strength
[72] grounding 0.0-1.0, vertical stability
[73] verticality -1.0-1.0, up/down tendency
[74] rotation 0.0-1.0, gyro magnitude
[75] coherence 0.0-1.0, latent consistency
[76:128] zeros (Swift fills these)After Swift getDynamics128() overlay
[63] pose.meanX (OVERWRITES z[15] range, actually slot 63 in 0-indexed 128D)
[64] pose.meanY (OVERWRITES norm — this is the existing conflict)
...CRITICAL BUG IDENTIFIED: The CLAUDE.md description says [63:69] are pose features. The Rust `flatten_latent()` computes norm at [64], speed at [65], etc. The Swift layer OVERWRITES [63:69] with pose features. This means norm, speed, curvature are destroyed by Swift. This is the existing production behavior — DiffusionService (and hub) receive pose features at [63:69], NOT norm/speed/curvature.
This is deliberate: the MotionMixApp.swift comment at line 1061 says "Canonical 128D: [0:76] from Echelon, [76:128] from sensors (Swift)" but in reality Swift ALSO overwrites [63:69] with pose.
The SAN receives the correct values directly from `core.latent()` (not from the 128D flat vector), so SAN is unaffected by this overwrite. DiffusionService and Hub receive the overwritten version.
Resolved 128D Layout for Temporal Integration
Do NOT shift slots. Instead, populate [69:74] BEFORE Swift pose overwrite, since [63:69] is the pose overwrite zone. The temporal block at [69:75] is outside the pose-overwrite range:
[0:63] Echelon latent (z, velocity, dynamics) Rust only
[63:69] Pose spatial features Swift overwrites
[69] BPM / 200.0 TEMPORAL — Swift overlay IF temporalSlotEnabled
[70] Tempo confidence TEMPORAL — Swift overlay IF temporalSlotEnabled
[71] Body phase (0-1) TEMPORAL — Swift overlay IF temporalSlotEnabled
[72] Phrase position (0-1) TEMPORAL — Swift overlay IF temporalSlotEnabled
[73] Swing amount (0-1) TEMPORAL — Swift overlay IF temporalSlotEnabled
[74] (rotation from Rust — leave unchanged)
[75] Modality mask / 15.0 Swift overlay (existing behavior)
[76:100] Mocopi 24D Swift overlay (existing behavior)
[100:102] Pocket IMU Swift overlay (existing behavior)
[102:104] Watch features Swift overlay (existing behavior)
[104:128] Reserved zerosNote: When temporalSlotEnabled=false, [69:73] carry Rust's periodicity/grounding/verticality/rotation — which is the existing behavior, and DiffusionService V5 was trained on this. Do NOT change the Rust flatten_latent() layout.
---
5. THE TEMPORAL SLOT SPEC
Slot Definitions (indices in the 128D canonical vector)
| Slot | Name | Source | Range | Smoothing |
|---|---|---|---|---|
| [69] | `tempo_bpm_n` | Rust autocorrelation OR external push | 0.0-1.0 (×200 to get BPM) | EMA α=0.85, 1-frame lag |
| [70] | `tempo_confidence` | From autocorrelation strength or push.confidence | 0.0-1.0 | EMA α=0.7 |
| [71] | `body_phase` | Free-running from tempo OR body phase tracker | 0.0-1.0 | No smooth (direct) |
| [72] | `phrase_position` | SANService.output.phase OR 0.0 | 0.0-1.0 | EMA α=0.5 |
| [73] | `swing_n` | From Mocopi beat analysis OR 0.0 | 0.0-1.0 (maps -0.5→+0.5 swing) | EMA α=0.9 |
Value encoding
- tempo_bpm_n: `bpm / 200.0`. Range [0.15, 1.0] for typical dance music (30-200 BPM). Value 0.0 = unknown.
- tempo_confidence: 0.0 means "use rule-based fallback", 1.0 means "trust fully". Threshold at 0.4 to gate ConditioningEncoder reliance.
- body_phase: Wraps [0,1) per body cycle. Advance formula: `phase += dt * (bpm/60.0); if phase >= 1.0: phase -= 1.0`.
- phrase_position: Wraps [0,1) per musical phrase (4 bars typical). Source: SANService.output.phase when NHA is trained; otherwise SANService.output.phrase_stable > 0.5 → sustained position.
- swing_n: Encodes `(swing_amount + 0.5)`. 0.5 = no swing. 0.0 = maximum lagging. 1.0 = maximum rushing. Source: Mocopi hip velocity asymmetry between on-beats and off-beats.
Smoothing requirements
- Tempo: Do NOT remove smoothing — raw tempo jumps cause clicking in pattern transitions. EMA at α=0.85 gives ~0.1s settling time at 60Hz.
- Phase: No smoothing on the phase value itself — it must advance monotonically. Smooth tempo first, derive phase.
- Confidence: Smooth to avoid rapid flip-flop between autocorrelation and external estimates.
- Swing: Heavily smoothed (α=0.9) — subtle parameter, never jump it.
Tempo estimator priority order
1. Motion-bridge `MotionFrame.tempo_bpm` from Mocopi (confidence=0.9 when isFresh)
2. SAN NHA learned phase (confidence derived from SANService.calibrationConfidence)
3. `EchelonBridge.getTemporalState()` existing method (uses Rust autocorrelation at [69:72])
4. Zeros (temporalSlotEnabled=false fallback, existing behavior)
---
6. ANTI-PATTERNS
Do NOT: Keep DELL's frame_count
`DELLCoordinator` has `frames_per_beat` (config field, default ~24 frames at 60Hz = 150BPM). This is a hardcoded metronome. Temporal integration does NOT modify this — the DELL metronome drives the slow equilibrium update cadence, not the audio output tempo. These are separate concerns. Do not try to synchronize DELL's internal beat clock to body-derived tempo; DELL's two-timescale design is correct.
Do NOT: Bypass DiffusionService priority chain
When temporal slots improve DiffusionService's CoreML output, the temptation is to short-circuit to CoreML directly from the temporal estimator. Do not do this. The Hub > CoreML > Rules chain exists for latency and reliability reasons. Hub predictions are pre-computed server-side and arrive with sub-50ms latency. Bypassing Hub defeats the entire architecture.
Do NOT: Break SAN's mixFactor blending
`SANOutputFFI.mix_factor` starts at 0.0 and ramps up as calibration improves. `EchelonBridge.san.blend()` uses this to fade in SAN control. Temporal slots must not assume SAN is calibrated — check `san.calibrationConfidence` before relying on `san.output.phase` for phrase position.
Do NOT: Clobber the existing Hub fallback on disconnect
`PhoneHubClient.isConnected` = false correctly falls through to CoreML. Do not add temporal confidence checks inside the Hub priority gate. Temporal confidence is only relevant at the CoreML conditioning level, not the Hub availability check.
Do NOT: Break the rule-based fallback on CoreML load failure
`DiffusionService.isAvailable` = false when models aren't in the bundle. The rule-based fallback at priority 3 must never depend on temporal slot validity. Rules work on `latent.energy`, `latent.tension`, `latent.groove` — none of which are in the 128D vector.
Do NOT: Modify flatten_latent() layout in san/mod.rs
The SAN V5 weights were trained on the current layout. Changing slot indices in `flatten_latent()` would require a full V6 retrain. The temporal overlay is applied in Swift (EchelonBridge.getDynamics128) ON TOP of the Rust output, not inside Rust. The distinction matters: Rust's SANPipeline reads latent directly (not from the 128D flat vector), so Rust is immune to the Swift overlay.
---
7. VALIDATION HOOKS
Instrumentation (shipping in production, always on)
// EchelonBridge.step(), every 60 frames:
if bridgeStepCount % 60 == 0 {
let dyn = getDynamics128() ?? [Float](repeating: 0, count: 128)
let tempo = dyn[69] * 200.0
let conf = dyn[70]
let phase = dyn[71]
let phraseP = dyn[72]
let swing = dyn[73] - 0.5
let temporalOK = conf > 0.01
print("[EchelonL4] temporal=\(temporalSlotEnabled) tempo=\(String(format:"%.1f",tempo)) conf=\(String(format:"%.2f",conf)) phase=\(String(format:"%.3f",phase)) phrasePos=\(String(format:"%.3f",phraseP)) swing=\(String(format:"%.2f",swing)) ok=\(temporalOK)")
}Expected baseline output (temporalSlotEnabled=true, good Mocopi signal):
[EchelonL4] temporal=true tempo=124.0 conf=0.87 phase=0.412 phrasePos=0.250 swing=0.02 ok=trueExpected output (temporalSlotEnabled=false, not yet enabled):
[EchelonL4] temporal=false tempo=0.0 conf=0.00 phase=0.000 phrasePos=0.000 swing=0.00 ok=falseLive test protocol: body slow → music slows
1. Launch MotionMixApp with temporalSlotEnabled=false (baseline)
2. Record AudioEngine BPM from StrudelEngine.bpm over 60s while dancing at steady pace
3. Flip temporalSlotEnabled=true (via SettingsView toggle or debug launch arg)
4. Repeat with same dancer, same pace
5. Compare: with temporal enabled, StrudelEngine.bpm variance should decrease by >30
6. Slow dancer deliberately by 20
Pass criterion: Body tempo change propagates to DiffusionService conditioning within 3 bar boundaries (~6s at 120BPM). No audio pop/click during propagation.
Latency requirement
End-to-end: body movement → temporal slot update → DiffusionService conditioning change.
- Body tempo change detected: frame 0 (immediate via autocorrelation, or ~2 beats via Mocopi)
- getDynamics128() updated: frame 0 (in-path, no async)
- DiffusionService.generate() called: next bar boundary (~30 frames at 120BPM)
- ConditioningEncoder re-run: at bar boundary (gated by minGenerationInterval=2.0s)
- AudioEngine pattern updated: at bar boundary + crossfade duration (~0.5s)
Total latency: 2.0-2.5s (bounded by DiffusionService.minGenerationInterval, not by temporal slot propagation). To improve: lower minGenerationInterval to 1.0s once temporal slots are validated.
Stability checks
- No audio clicking: DiffusionService crossfades patterns — temporal improvement only affects what pattern is generated, not how it transitions
- No phase discontinuity: body_phase must advance monotonically; if Mocopi signal drops (isFresh=false), fall back to free-running phase advancement
- No NaN in temporal slots: clamp all values before writing. Swing: clamp [-0.5,0.5] → [0,1]. Tempo: clamp [30,200]. Confidence: clamp [0,1].
---
8. ROLLBACK PATH
Feature flag (primary kill switch)
// EchelonBridge.swift
var temporalSlotEnabled: Bool = false // off by default
// SettingsView.swift or launch argument:
// --enable-temporal-slotsWhen `temporalSlotEnabled=false`: `getDynamics128()` skips the temporal overlay entirely. Rust fills [69:75] with its autocorrelation values (existing behavior). No change to DiffusionService, SANService, or AudioEngine.
Per-session override
// In MotionMixState or AppStorage:
@AppStorage("echelonTemporalEnabled") var temporalEnabled = falsePersists across launches. User can disable without rebuilding.
FFI-level flag (for A/B testing without rebuilding iOS)
// ffi.rs addition
static TEMPORAL_ENABLED: AtomicBool = AtomicBool::new(false);
#[no_mangle]
pub extern "C" fn echelon_set_temporal_enabled(enabled: bool) {
TEMPORAL_ENABLED.store(enabled, Ordering::Relaxed);
}// EchelonBridge.swift, init:
echelon_set_temporal_enabled(false) // starts disabledBuild identifier in logs
// AudioEngine startup or EchelonBridge init:
print("[AudioEngine] temporal_slots=\(echelonBridge?.temporalSlotEnabled ?? false) build=\(Bundle.main.buildNumber)")Allows correlating production audio complaints with temporal slot activation state in device logs.
Revert procedure (if production audio regresses)
1. Set `temporalSlotEnabled = false` in SettingsView → immediate effect, no rebuild required
2. If full rollback needed: remove `pushTemporal()` call sites in motion-bridge, rebuild Rust, redeploy `libechelon_ios.a`
3. V6 ConditioningEncoder retrain with temporal labels: safe to roll back by reverting to V5 weights (128D→104D shim already exists in DiffusionService lines 258-266)
---
IMPLEMENTATION ORDER
The order matters because each step can be validated independently:
1. Step 1 — Instrumentation only (1 day)
- Add the `bridgeStepCount
- `temporalSlotEnabled = false`, so behavior is unchanged
- Verify that tempo/phase/periodicity fields from Rust are non-zero and sensible
2. Step 2 — Swift temporal overlay (1 day)
- Implement `TemporalStateCache` struct and `pushTemporal()` method in EchelonBridge
- Populate cache from existing `SANService.output.phase` (phrase position) and `getTemporalState()` (tempo)
- `temporalSlotEnabled = true` in debug builds only
- Verify instrumentation log shows correct values
3. Step 3 — Mocopi tempo feed (2 days)
- Add motion-bridge `ModulationRouter` → `echelon_push_temporal()` on BeatSync events
- Rebuild libechelon_ios.a with FFI addition
- Verify Mocopi-derived tempo reaches [69] with confidence > 0.8 when Mocopi is present
4. Step 4 — Validation (1 day)
- Run live test protocol (body slow → music slows)
- Measure latency (should be < 2.5s end-to-end)
- Check stability (no pops, no NaN, no phase jumps)
5. Step 5 — Production enable (after V6 retrain)
- Set `temporalSlotEnabled = true` as the default
- Retrain ConditioningEncoder with temporal-enriched training data
- Remove 104D truncation shim in DiffusionService
---
KEY FORMULAS
Tempo normalization: `buf[69] = bpm / 200.0` — linear, no log scale. 60BPM → 0.3, 120BPM → 0.6, 180BPM → 0.9.
Phase advancement (free-running): `phase = fmod(phase + dt * (bpm / 60.0), 1.0)` — this is the body cycle position, not the musical measure position.
Swing encoding: `buf[73] = (swing_amount + 0.5).clamped(to: 0...1)` where `swing_amount` is in [-0.5, 0.5]. 0.0 = maximum lag, 0.5 = straight, 1.0 = maximum rush.
Confidence weighting for tempo blend: `effective_tempo = confidence pushed_bpm + (1-confidence) autocorr_bpm` — used in `SimpleLatentUpdater.push_temporal()`.
Bar trigger (existing, do not change): `echelon_get_bar_trigger()` returns 1 when `latent.phase < 0.05 && latent.periodicity > 0.3`. This works on the Rust-internal phase, not the Swift-overlaid temporal slot.
Promotion Decision
Promote into a technical note or architecture paper with implementation anchors.
Source Anchor
crucible-output/echelon-l4/03-architecture.md
Detected Structure
Method · Evaluation · Code Anchors · Architecture · is Stage Research