feat(adapters): UltrasonicReplayAdapter — the first adapter for Modality::Ultrasonic by ruvnet · Pull Request #11 · ruvnet/rufield · GitHub
Skip to content

feat(adapters): UltrasonicReplayAdapter — the first adapter for Modality::Ultrasonic - #11

Merged
ruvnet merged 1 commit into
mainfrom
claude/ultrasonic-adapter-batvu
Aug 25, 2026
Merged

ruvnet merged 1 commit into
mainfrom
claude/ultrasonic-adapter-batvu

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Modality::Ultrasonic has been registry code 7 since v0.1 with nothing implementing it. This is that implementation.

It replays .ultrasonic.jsonl recordings from BatVu — a handheld sonar that runs entirely in a phone browser: a 17.5–20.5 kHz chirp out of the speaker, a matched filter over the microphone, and a range profile of echo amplitude against distance along one beam.

One new module in rufield-adapters, its pub mod / pub use lines, a dev-dependency on rufield-privacy, a test fixture, and a README section. No other crate changes; the workspace manifest needs nothing.


The tensor is [Range], and that is an argument rather than a default

[Angle, Range] is the obvious shape for a sonar and it would be a lie here. FieldAxis::Angle means angle-of-arrival bins — the output of an array that has measured direction. BatVu has one microphone. The direction attached to an echo is where the operator happened to be pointing the phone, which is pose, not signal.

So the profile is rank-1 and the beam rides in SensorDescriptor::orientation_xyzw, as the minimal rotation taking sensor-local +Z onto it. Roll about the boresight is unobservable with one transducer pair, so the minimal rotation is the one that asserts nothing about the axis nobody measured.

Worth noting for the record: validate_evidence_at would have accepted any of the three candidate shapes. There is no Modality::Ultrasonic branch in rufield-core and this PR does not add one — the choice is enforced by the adapter's own tests instead.

Two output modes, and the safe one is the default

The full per-bin profile is a sensor frame, and RuField classifies the analogous per-subcarrier CSI frame P0. Calling ours something gentler would be special pleading for our own modality.

mode tensor class Destination::Network under the stock policy
RangeProfile every recorded bin P0 Deny (edge-local only)
CoarseProfile (default) 32 max-pooled bins P1 Allow

Max-pooling rather than averaging is deliberate: a mean smears a sharp echo into its neighbours and a wall stops looking like a wall, whereas a maximum keeps every peak the detector cared about and discards only the shape between them — which is precisely the part worth not transmitting.

the_stock_privacy_policy_lets_the_coarse_profile_onto_a_network_and_refuses_the_raw_one checks this with DefaultPrivacyGuard itself, rather than by reading the class off the struct and agreeing with ourselves.

max_privacy_class is set per mode and not as a .max() over what is emitted — PrivacyClass orders P0 < P5, so the raw mode's P0 is numerically the smallest while being the most sensitive, and a maximum would report P1 for it.

A recording cannot relabel itself into a higher trust tier

Every line declares its source, and the adapter is constructed with the source the deployment is willing to accept. A file claiming device_capture handed to an adapter configured for simulation is refused outright — and so is the reverse, so neither direction is a silent reinterpretation. provenance.synthetic is then derived from the accepted source rather than taken from a field the file controls.

The parser is hostile on purpose

Modelled on quantum_rf_replay, which is the more defensive of the two existing parsers:

  • line length capped before serde_json sees the bytes, so an oversized line cannot allocate its way past the check
  • deny_unknown_fields, so a stray key is a parse error rather than attacker-controlled CPU spent parsing and discarding
  • bounded profile bins, detections and total pings
  • finiteness and sign checked on every numeric — a NaN survives FieldTensor::validate (which checks only shape and axis rank), signs fine, verifies fine, and then fails to deserialize as an f32 on the far side of the wire, which is the worst place to find it
  • strictly increasing timestamps, and a year-2100 clock bound. This one is not tidiness: TrustVerifier's watermark only ever advances, so a single event with a saturated clock permanently blocks every honest event from that device
  • identifiers rejected for control characters and length; a recording that changes device_id midway rejected outright
  • a detection lying outside the profile it came from refused as incoherent, rather than admitted into a fused map with no measurement behind it
  • no trailing-line tolerance: every malformed line is a hard error with its line number

Each of these has a test that names the input it stops.

The honest negative result

The adapter sets features["range_m"] — the key the fusion window actually reads, as against the typed Observation::range_m field, which it also sets and which fusion never looks at.

It deliberately does not set features["presence"], although doing so would light up the shipped person_present rule and make a demo work. An echo at 2.4 m is a surface. One transducer pair cannot tell a person from a coat on the back of a chair, and a range-only sensor reporting presence is asserting exactly that distinction.

The consequence is that with the shipped room_state.toml, a BatVu scan produces no inferences at all — for two independent reasons, both pinned by the_shipped_rules_have_no_predicate_a_range_only_sensor_can_drive:

  1. No rule lists "ultrasonic" among its inputs, so no rule ever sees the events.
  2. Adding one would not help. The engine's feat vocabulary — motion_energy, breathing_band, transient, presence, posture_sit, posture_lie — is entirely statements about a body. range_m reaches the window but has no feat arm, and the only predicate that reads it (range_depart) additionally requires motion_energy > 0.4, which a phone standing still cannot supply.

The gap is in the rule vocabulary, not the adapter: RuField v0.1 has no predicate for static geometry. Closing it is a change to rufield-fusion and out of scope here.

observation.attributes["evidence_kind"] is set to synthetic_replay / captured_replay following quantum_rf_support.rs, because bearing_trust's simulation policy rejects a synthetic event whose evidence kind is <missing>. An ultrasonic event has no bearing to fuse and should never reach that engine, but "should never" is not a mechanism.

Cross-language conformance

tests/fixtures/batvu_living_room.ultrasonic.jsonl is not hand-written. It is generated by BatVu's own npm run artifacts from a 72-ping simulated sweep of its living-room scene, written by the TypeScript emitter in @batvu/field, and copied here verbatim. If BatVu's emitter and this parser ever drift apart, a build fails in one repository or the other rather than an ingest failing in a deployment.

tests/ultrasonic_fusion.rs drives that fixture through the whole stack: parse → calibrate → sign → validate_evidence_at → TrustVerifier → DefaultPrivacyGuard → RuFieldFusion::ingest.

Honesty

Every current BatVu recording is its own simulator's output. The events are therefore synthetic: true, and captured_replay() and production() reject them outright — as they should. The pipeline is real and the fixture is a real cross-language check; the measurements are not yet from a phone, and the README says so in both places it introduces the adapter.

Verification

  • 32 unit tests + 7 integration tests, all passing
  • cargo test --workspace green
  • cargo fmt --all -- --check clean
  • cargo clippy --workspace --all-targets -- -W clippy::all introduces no new warnings (the two too_many_arguments are pre-existing)

🤖 Generated with Claude Code

https://claude.ai/code/session_01BRtoJRh2M4LV3vLcSg2nSX


Generated by Claude Code

…ity::Ultrasonic

`Modality::Ultrasonic` has been registry code 7 since v0.1 with nothing
implementing it. This is that implementation: it replays `.ultrasonic.jsonl`
recordings from BatVu (https://github.com/ruvnet/batvu), a handheld sonar that
runs entirely in a phone browser — a 17.5-20.5 kHz chirp out of the speaker, a
matched filter over the microphone, and a range profile of echo amplitude
against distance along one beam.

## The tensor is [Range], and that is an argument, not a default

`[Angle, Range]` is the obvious shape for a sonar and it would be a lie.
`FieldAxis::Angle` means angle-of-arrival bins — what an array produces when it
has measured direction. BatVu has one microphone. The direction attached to an
echo is where the operator happened to be pointing, which is pose, not signal,
and it rides in `SensorDescriptor::orientation_xyzw` as the minimal rotation
taking sensor-local +Z onto the beam. Roll about the boresight is unobservable
with one transducer pair, so the minimal rotation is the one that asserts
nothing about the axis nobody measured.

## Two output modes; the safe one is the default

The full per-bin profile is a sensor frame, and RuField classifies the
analogous per-subcarrier CSI frame P0. Calling ours gentler would be special
pleading, so `UltrasonicOutput::RangeProfile` is P0 and edge-local.
`CoarseProfile` — the default — max-pools to 32 bins and is P1, which the stock
policy allows onto a network. Max-pooling rather than averaging: a mean smears a
sharp echo into its neighbours and a wall stops looking like a wall.
`the_stock_privacy_policy_lets_the_coarse_profile_onto_a_network_and_refuses_the_raw_one`
tests that with rufield's own guard rather than by reading the class off the
struct and agreeing with itself.

## A recording cannot relabel itself into a higher trust tier

Every line declares its `source`, and the adapter is constructed with the source
the deployment is willing to accept. A file claiming `device_capture` fed to an
adapter configured for simulation is refused outright — and so is the reverse,
so neither direction is a silent reinterpretation. `provenance.synthetic` is
then derived from the accepted source rather than taken from a wire field.

## The parser is hostile on purpose

Line length capped before serde sees the bytes, so an oversized line cannot
allocate its way past the check. `deny_unknown_fields`, bounded arrays,
finiteness and sign checked on every numeric, strictly increasing timestamps,
identifiers rejected for control characters, and a detection that lies outside
the profile it came from refused as incoherent. Each of these has a test that
names the input it stops. The timestamp bound in particular is not tidiness:
`TrustVerifier`'s watermark only ever advances, so one event with a saturated
clock permanently blocks every honest one from that device.

## The honest negative result

The adapter sets `features["range_m"]` — the key the fusion window actually
reads, as against the typed `Observation::range_m` field, which it also sets and
which fusion never looks at. It deliberately does NOT set `features["presence"]`,
though doing so would light up the shipped `person_present` rule and make a demo
work. An echo at 2.4 m is a surface; one transducer pair cannot tell a person
from a coat on the back of a chair.

The consequence is that with the shipped rules a BatVu scan produces no
inferences at all, for two independent reasons: no rule lists "ultrasonic", and
no rule could help if it did, because the engine's feature vocabulary is
entirely statements about a body and `range_m` has no `feat` arm. RuField v0.1
has no predicate for static geometry.
`the_shipped_rules_have_no_predicate_a_range_only_sensor_can_drive` asserts
exactly that. Closing it is a change to rufield-fusion, not to an adapter.

## Cross-language conformance

`tests/fixtures/batvu_living_room.ultrasonic.jsonl` is not hand-written: it is
generated by BatVu's own `npm run artifacts` from a 72-ping simulated sweep and
copied here verbatim. If BatVu's TypeScript emitter and this parser drift apart,
a build fails in one repository or the other rather than an ingest failing in a
deployment.

32 unit tests, 7 integration tests. Workspace clippy and fmt clean; no other
crate is modified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRtoJRh2M4LV3vLcSg2nSX
@ruvnet
ruvnet marked this pull request as ready for review August 25, 2026 23:57
@ruvnet
ruvnet merged commit 9955672 into main Aug 25, 2026
1 check passed
ruvnet added a commit to ruvnet/batvu that referenced this pull request Aug 25, 2026
…ire (#3)

A handheld ultrasonic sonar that runs entirely in a phone browser: a
17.5-20.5 kHz chirp out of the speaker, a matched filter over the microphone,
and a log-odds occupancy map painted onto a plan-position display. Everything
but the transducer runs on a Linux CI box.

Rust/WASM DSP core, TypeScript pipeline, @metaharness/horizon for scan control
and @metaharness/flywheel for policy evolution, and an iPhone web app in 28 KB
of JavaScript with no framework.

The output speaks RuField MFS: @batvu/field writes .ultrasonic.jsonl, which
ruvnet/rufield#11 parses, signs and drives through trust verification, the
privacy guard and the fusion engine. The same recording is a test fixture in
both repositories, so schema drift fails a build rather than an ingest.

@batvu/memory answers "have I been here before?" without being able to answer
"where am I" — a 128-number descriptor invariant under rotation about gravity,
which is exactly the one degree of freedom orientation-only pose cannot know.

Two correctness bugs found by adversarial review, both of which had passed
every existing test:

- classifyEmission was called in exactly one place, the flywheel's simulated
  scorer. The guard standing between a scoring function and a phone that
  shrieks was not on the path to the speaker. It is now the first thing
  AudioSession.start() does. Four NaN inputs that deleted checks rather than
  failing them are fixed alongside it.

- The time origin took the global maximum over a record containing about four
  transmit blasts, so noise chose which one. It corrupts the pose tag rather
  than the range: echoes up to three pings old, filed under the attitude now.
  No test saw it because the simulator renders one blast per record.

21 ADRs, a threat model, and a committed artifacts/ bundle carrying the file,
the command and the SHA-256 behind every number quoted.

90 Rust tests, 146 TypeScript tests, 20/20 end-to-end checks in a real browser.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant