Grand Diomande Research · Full HTML Reader

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:

Embodied Trajectory Systems architecture technical paper candidate score 36 .md

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.

rust
// 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.

rust
// 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.

rust
// 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`:

rust
// 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:

rust
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:

swift
// 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 = false

B. Modify `getDynamics128()` to overlay temporal slots AFTER Rust fills them, but only when temporalSlotEnabled and a fresh estimate exists:

swift
// 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:

swift
// 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 zeros

Note: 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)

SlotNameSourceRangeSmoothing
[69]`tempo_bpm_n`Rust autocorrelation OR external push0.0-1.0 (×200 to get BPM)EMA α=0.85, 1-frame lag
[70]`tempo_confidence`From autocorrelation strength or push.confidence0.0-1.0EMA α=0.7
[71]`body_phase`Free-running from tempo OR body phase tracker0.0-1.0No smooth (direct)
[72]`phrase_position`SANService.output.phase OR 0.00.0-1.0EMA α=0.5
[73]`swing_n`From Mocopi beat analysis OR 0.00.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)

swift
// 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=true

Expected 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=false

Live 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)

swift
// EchelonBridge.swift
var temporalSlotEnabled: Bool = false  // off by default

// SettingsView.swift or launch argument:
// --enable-temporal-slots

When `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

swift
// In MotionMixState or AppStorage:
@AppStorage("echelonTemporalEnabled") var temporalEnabled = false

Persists across launches. User can disable without rebuilding.

FFI-level flag (for A/B testing without rebuilding iOS)

rust
// 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);
}
swift
// EchelonBridge.swift, init:
echelon_set_temporal_enabled(false)  // starts disabled

Build identifier in logs

swift
// 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