diff --git a/.github/workflows/scorecard-enforcer.yml b/.github/workflows/scorecard-enforcer.yml deleted file mode 100644 index df92dec..0000000 --- a/.github/workflows/scorecard-enforcer.yml +++ /dev/null @@ -1,114 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Prevention workflow — runs OpenSSF Scorecard and enforces the repo-side, -# deterministically-satisfiable posture. The aggregate Scorecard number embeds -# org-plane checks (Branch-Protection, Code-Review) that cannot be satisfied from -# inside the repository, so it is reported for visibility but NOT hard-gated here -# (that would be an un-winnable gate — see docs / the PR FLAGS section). What we -# DO hard-gate is everything the repository itself controls: SECURITY.md, a -# Dependabot config, SHA-pinned actions, and per-workflow permissions blocks. -name: OpenSSF Scorecard Enforcer - -on: - push: - branches: [main] - schedule: - - cron: '0 6 * * 1' # Weekly on Monday - workflow_dispatch: - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - scorecard: - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write # For OIDC - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload SARIF - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: results.sarif - - - name: Report posture (informational) - run: | - # Scorecard SARIF encodes PER-CHECK results, not a single aggregate - # score at .runs[0].tool.driver.properties.score — the old parse always - # yielded 0 and always failed. Full per-check detail is now in the - # Security tab (SARIF) and in the scorecard.yml JSON artifact. The - # aggregate (which includes owner-plane Branch-Protection / Code-Review) - # is tracked there, not hard-gated in-repo. - echo "OpenSSF Scorecard SARIF uploaded to the Security tab." - echo "Repo-side posture is enforced by the check-critical job below." - - # Deterministic, repo-controllable posture — every item here is satisfiable - # without owner/org-plane settings, so a red here is a real, actionable defect. - check-critical: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Require SECURITY.md - run: | - if [ ! -f "SECURITY.md" ]; then - echo "::error::SECURITY.md is required" - exit 1 - fi - - - name: Require Dependabot config - run: | - if [ ! -f ".github/dependabot.yml" ] && [ ! -f ".github/dependabot.yaml" ]; then - echo "::error::.github/dependabot.yml is required (automated dependency updates)" - exit 1 - fi - - - name: Require SHA-pinned actions - run: | - # OpenSSF Pinned-Dependencies: every `uses:` must reference a 40-char - # commit SHA, never a floating @vN / @branch tag. - unpinned=$(grep -rnE "uses:[[:space:]]*[^#]*@v[0-9]" .github/workflows/*.yml 2>/dev/null | grep -vE "^\s*#" || true) - if [ -n "$unpinned" ]; then - echo "::error::Unpinned actions found (must pin to a full commit SHA):" - echo "$unpinned" - exit 1 - fi - echo "All actions are SHA-pinned." - - - name: Require per-workflow permissions blocks - run: | - # OpenSSF Token-Permissions: every workflow declares a top-level - # least-privilege permissions block. - missing="" - for f in .github/workflows/*.yml; do - if ! grep -qE '^permissions:' "$f"; then - missing="$missing $f" - fi - done - if [ -n "$missing" ]; then - echo "::error::Workflows missing a top-level permissions block:$missing" - exit 1 - fi - echo "All workflows declare a top-level permissions block." diff --git a/Cargo.lock b/Cargo.lock index 132aaa7..0d4f8ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ dependencies = [ "sensors", "serde", "serde_json", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/neurophone-android/Cargo.toml b/crates/neurophone-android/Cargo.toml index d438412..411ceee 100644 --- a/crates/neurophone-android/Cargo.toml +++ b/crates/neurophone-android/Cargo.toml @@ -3,9 +3,14 @@ name = "neurophone-android" description = "NeuroPhone Android JNI Bindings" version.workspace = true edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true [lib] -crate-type = ["cdylib"] +# "lib" (rlib) so host `cargo test` can build+run the bridge unit tests; +# "cdylib" for the actual Android JNI shared object. +crate-type = ["lib", "cdylib"] [dependencies] neurophone-core = { path = "../neurophone-core" } @@ -14,6 +19,7 @@ sensors = { path = "../sensors" } jni = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tokio = { workspace = true } diff --git a/crates/neurophone-android/src/error.rs b/crates/neurophone-android/src/error.rs new file mode 100644 index 0000000..5b2b91d --- /dev/null +++ b/crates/neurophone-android/src/error.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Error type bridging `neurophone-core` and `jni` failures across the boundary. + +use thiserror::Error; + +/// Unified error for the JNI bridge. +/// +/// The `jni` `with_env`/`resolve` machinery only requires the closure error to +/// implement [`std::error::Error`]; this enum lets a single `?` propagate both +/// JNI failures and core failures, which `ThrowRuntimeExAndDefault` then turns +/// into a Java `RuntimeException` (never an unwind across FFI). +#[derive(Error, Debug)] +pub enum JniBridgeError { + /// A failure raised by the `jni` crate itself. + #[error(transparent)] + Jni(#[from] jni::errors::Error), + /// A failure raised by `neurophone-core`. + #[error(transparent)] + Core(#[from] neurophone_core::NeurophoneError), + /// A bridge-layer precondition failure (e.g. "not initialised"). + #[error("{0}")] + Msg(String), +} diff --git a/crates/neurophone-android/src/lib.rs b/crates/neurophone-android/src/lib.rs index 8d7634d..252d910 100644 --- a/crates/neurophone-android/src/lib.rs +++ b/crates/neurophone-android/src/lib.rs @@ -1,6 +1,214 @@ // SPDX-License-Identifier: MPL-2.0 -//! Stub implementation +//! NeuroPhone Android JNI bindings — class `ai.neurophone.NativeLib`. +//! +//! This is a pure ABI boundary: every `#[no_mangle]` export marshals JNI +//! arguments to/from Rust and delegates to [`state`], which holds the live +//! [`neurophone_core::NeuroSymbolicSystem`] and contains all bridge logic. Under +//! jni 0.22, native methods receive an [`EnvUnowned`] and call `with_env`, which +//! wraps the body in `catch_unwind` so a panic can never unwind across FFI; the +//! [`ThrowRuntimeExAndDefault`] policy converts any error into a Java +//! `RuntimeException`. +//! +//! There is no unsafe *code* here — every JVM interaction goes through jni's safe +//! `with_env`/`resolve` API. The crate cannot use `#![forbid(unsafe_code)]` only +//! because the JNI ABI export attribute is spelled `#[unsafe(no_mangle)]`; this is +//! the estate-sanctioned FFI boundary crate (Trust `[FFI]` policy), and the +//! exports are the sole reason the attribute appears. +//! +//! The JNI surface mirrors the contract in `docs/migrations/JNI-SURFACE-AUDIT.adoc` +//! (11 methods). It is gossamer-independent: gossamer changes *who* loads and +//! calls this cdylib, not this ABI. -pub fn hello() -> &'static str { - "hello" +mod error; +mod sensor_map; +mod state; + +use error::JniBridgeError; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JClass, JFloatArray, JString}; +use jni::refs::Reference; +use jni::sys::{jboolean, jint, jlong}; +use jni::EnvUnowned; +use neurophone_core::QueryRoute; + +// jni 0.22 models `jboolean` as a real Rust `bool`. +const JNI_TRUE: jboolean = true; + +/// `init(configJson: String?): Boolean` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_init<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, + config_json: JString<'c>, +) -> jboolean { + unowned_env + .with_env(|_env| -> Result { + let json = if config_json.is_null() { + None + } else { + Some(config_json.to_string()) + }; + state::init(json.as_deref())?; + Ok(JNI_TRUE) + }) + .resolve::() +} + +/// `start(): Boolean` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_start<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) -> jboolean { + unowned_env + .with_env(|_env| -> Result { + state::start()?; + Ok(JNI_TRUE) + }) + .resolve::() +} + +/// `stop()` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_stop<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) { + unowned_env + .with_env(|_env| -> Result<(), JniBridgeError> { + state::stop(); + Ok(()) + }) + .resolve::() +} + +/// `isRunning(): Boolean` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_isRunning<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) -> jboolean { + unowned_env + .with_env(|_env| -> Result { Ok(state::is_running()) }) + .resolve::() +} + +/// `processSensor(sensorType: Int, values: FloatArray, timestamp: Long, accuracy: Int): Boolean` +/// +/// `accuracy` is accepted for ABI stability and intentionally ignored (the core +/// does not consume it). +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_processSensor<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, + sensor_type: jint, + values: JFloatArray<'c>, + timestamp: jlong, + _accuracy: jint, +) -> jboolean { + unowned_env + .with_env(|env| -> Result { + let name = sensor_map::name_from_id(sensor_type) + .ok_or_else(|| JniBridgeError::Msg(format!("unknown sensor id {sensor_type}")))?; + let len = values.len(env)?; + let mut buf = vec![0f32; len]; + values.get_region(env, 0, &mut buf)?; + state::process_sensor(name, buf, timestamp.max(0) as u64)?; + Ok(JNI_TRUE) + }) + .resolve::() +} + +/// `query(message: String, preferLocal: Boolean): String` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_query<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, + message: JString<'c>, + prefer_local: jboolean, +) -> JString<'c> { + unowned_env + .with_env(|env| -> Result<_, JniBridgeError> { + let msg = message.to_string(); + let route = if prefer_local { + QueryRoute::Auto + } else { + QueryRoute::ForceCloud + }; + let resp = state::query(&msg, route)?; + Ok(JString::from_str(env, resp)?) + }) + .resolve::() +} + +/// `queryLocal(message: String): String` — forces the local model. +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_queryLocal<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, + message: JString<'c>, +) -> JString<'c> { + unowned_env + .with_env(|env| -> Result<_, JniBridgeError> { + let resp = state::query(&message.to_string(), QueryRoute::ForceLocal)?; + Ok(JString::from_str(env, resp)?) + }) + .resolve::() +} + +/// `queryClaude(message: String): String` — forces the cloud model. +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_queryClaude<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, + message: JString<'c>, +) -> JString<'c> { + unowned_env + .with_env(|env| -> Result<_, JniBridgeError> { + let resp = state::query(&message.to_string(), QueryRoute::ForceCloud)?; + Ok(JString::from_str(env, resp)?) + }) + .resolve::() +} + +/// `getNeuralContext(): String` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_getNeuralContext<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) -> JString<'c> { + unowned_env + .with_env(|env| -> Result<_, JniBridgeError> { + let ctx = state::neural_context()?; + Ok(JString::from_str(env, ctx)?) + }) + .resolve::() +} + +/// `getState(): String` — the `SystemState` as JSON. +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_getState<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) -> JString<'c> { + unowned_env + .with_env(|env| -> Result<_, JniBridgeError> { + let json = state::state_json()?; + Ok(JString::from_str(env, json)?) + }) + .resolve::() +} + +/// `reset()` +#[unsafe(no_mangle)] +pub extern "system" fn Java_ai_neurophone_NativeLib_reset<'c>( + mut unowned_env: EnvUnowned<'c>, + _class: JClass<'c>, +) { + unowned_env + .with_env(|_env| -> Result<(), JniBridgeError> { + state::reset()?; + Ok(()) + }) + .resolve::() } diff --git a/crates/neurophone-android/src/sensor_map.rs b/crates/neurophone-android/src/sensor_map.rs new file mode 100644 index 0000000..f379a21 --- /dev/null +++ b/crates/neurophone-android/src/sensor_map.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Android `Sensor.TYPE_*` id → canonical neurophone-core sensor name. +//! +//! This lookup used to live in the Kotlin `NativeLib` wrapper; per the JNI +//! surface audit it now lives in the Rust boundary so the name contract is +//! owned by the core stack, not the JVM shim. + +use jni::sys::jint; + +/// The five sensors neurophone consumes, keyed by the Android framework's +/// `Sensor.TYPE_*` integer constants. +const ID_TO_NAME: &[(jint, &str)] = &[ + (1, "accelerometer"), // Sensor.TYPE_ACCELEROMETER + (4, "gyroscope"), // Sensor.TYPE_GYROSCOPE + (2, "magnetometer"), // Sensor.TYPE_MAGNETIC_FIELD + (5, "light"), // Sensor.TYPE_LIGHT + (8, "proximity"), // Sensor.TYPE_PROXIMITY +]; + +/// Map an Android sensor type id to a canonical name, or `None` if unknown. +pub fn name_from_id(id: jint) -> Option<&'static str> { + ID_TO_NAME + .iter() + .find_map(|(k, v)| if *k == id { Some(*v) } else { None }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_ids_map() { + assert_eq!(name_from_id(1), Some("accelerometer")); + assert_eq!(name_from_id(4), Some("gyroscope")); + assert_eq!(name_from_id(2), Some("magnetometer")); + assert_eq!(name_from_id(5), Some("light")); + assert_eq!(name_from_id(8), Some("proximity")); + } + + #[test] + fn unknown_id_is_none() { + assert_eq!(name_from_id(99), None); + assert_eq!(name_from_id(-1), None); + assert_eq!(name_from_id(0), None); + } +} diff --git a/crates/neurophone-android/src/state.rs b/crates/neurophone-android/src/state.rs new file mode 100644 index 0000000..4d47513 --- /dev/null +++ b/crates/neurophone-android/src/state.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Global runtime-state holder for the JNI surface, plus the pure bridge +//! operations the JNI exports marshal into. +//! +//! All logic here is JVM-free and host-testable: the JNI layer in `lib.rs` only +//! does string/array marshalling and then calls these functions. The held +//! `Option` is the boundary's affine resource — `init` acquires it +//! (`None -> Some`) and `reset` consumes the running system via the core +//! `shutdown(self)` typestate (`Active -> Down`, dropped exactly once) before +//! reinstating a fresh one. + +use crate::error::JniBridgeError; +use neurophone_core::{ + phase, NeuroSymbolicSystem, NeurophoneError, QueryRoute, SensorEvent, SystemConfig, +}; +use std::sync::{Mutex, OnceLock}; + +/// The live system plus the metadata needed for `start`/`stop`/`reset`. +pub struct RuntimeState { + system: NeuroSymbolicSystem, + config: SystemConfig, + running: bool, +} + +static HOLDER: OnceLock>> = OnceLock::new(); + +fn holder() -> &'static Mutex> { + HOLDER.get_or_init(|| Mutex::new(None)) +} + +/// Acquire the lock, recovering (rather than panicking) if a previous holder +/// panicked while holding it. +fn guard() -> std::sync::MutexGuard<'static, Option> { + holder() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn parse_config(config_json: Option<&str>) -> Result { + match config_json { + Some(s) if !s.trim().is_empty() => serde_json::from_str::(s) + .map_err(|e| JniBridgeError::Msg(format!("invalid config JSON: {e}"))), + _ => Ok(SystemConfig::default()), + } +} + +/// `init(configJson)` — construct + initialise the system (acquire). Idempotent: +/// re-initialising replaces any prior instance, consuming it via `shutdown`. +pub fn init(config_json: Option<&str>) -> Result<(), JniBridgeError> { + let config = parse_config(config_json)?; + let system = NeuroSymbolicSystem::new(config.clone())?.initialize()?; + let mut g = guard(); + if let Some(prev) = g.take() { + // Consume the previous Active system exactly once (Active -> Down). + let _down = prev.system.shutdown(); + } + *g = Some(RuntimeState { + system, + config, + running: false, + }); + Ok(()) +} + +/// `start()` — mark the system running. Errors if not initialised. +pub fn start() -> Result<(), JniBridgeError> { + let mut g = guard(); + let st = g + .as_mut() + .ok_or_else(|| JniBridgeError::Msg("start() before init()".into()))?; + st.running = true; + Ok(()) +} + +/// `stop()` — mark the system not running. No-op if not initialised. +pub fn stop() { + if let Some(st) = guard().as_mut() { + st.running = false; + } +} + +/// `isRunning()` — true only if initialised and started. +pub fn is_running() -> bool { + guard().as_ref().map(|st| st.running).unwrap_or(false) +} + +fn require_running(g: &mut Option) -> Result<&mut RuntimeState, JniBridgeError> { + let st = g + .as_mut() + .ok_or_else(|| JniBridgeError::Msg("operation before init()".into()))?; + if !st.running { + return Err(JniBridgeError::Msg("operation before start()".into())); + } + Ok(st) +} + +/// `processSensor(...)` — feed one sensor sample through the system. +pub fn process_sensor( + sensor_name: &str, + values: Vec, + timestamp_ms: u64, +) -> Result<(), JniBridgeError> { + let mut g = guard(); + let st = require_running(&mut g)?; + let event = SensorEvent { + sensor_type: sensor_name.to_string(), + timestamp_ms, + values, + }; + st.system.process_sensor_event(&event)?; + Ok(()) +} + +/// `query(...)` / `queryLocal` / `queryClaude` — run inference, return the text. +pub fn query(message: &str, route: QueryRoute) -> Result { + let mut g = guard(); + let st = require_running(&mut g)?; + let result = st.system.query_routed(message, route)?; + Ok(result.response) +} + +/// `getNeuralContext()` — the LLM-context summary string. +pub fn neural_context() -> Result { + let g = guard(); + let st = g + .as_ref() + .ok_or_else(|| JniBridgeError::Msg("getNeuralContext() before init()".into()))?; + Ok(st.system.get_neural_context()) +} + +/// `getState()` — the `SystemState` as JSON. +pub fn state_json() -> Result { + let g = guard(); + let st = g + .as_ref() + .ok_or_else(|| JniBridgeError::Msg("getState() before init()".into()))?; + serde_json::to_string(&st.system.get_state()) + .map_err(|e| JniBridgeError::Msg(format!("state serialisation failed: {e}"))) +} + +/// `reset()` — consume the running system (`Active -> Down`, dropped once) and +/// reinstate a fresh one from the saved config, preserving the running flag. +pub fn reset() -> Result<(), JniBridgeError> { + let mut g = guard(); + let prev = g + .take() + .ok_or_else(|| JniBridgeError::Msg("reset() before init()".into()))?; + let running = prev.running; + let config = prev.config.clone(); + let _down = prev.system.shutdown(); // consume-once: Active -> Down + let system = rebuild(&config)?; + *g = Some(RuntimeState { + system, + config, + running, + }); + Ok(()) +} + +fn rebuild(config: &SystemConfig) -> Result, NeurophoneError> { + NeuroSymbolicSystem::new(config.clone())?.initialize() +} + +/// Test-only reset of the global holder so unit tests don't share state. +#[cfg(test)] +pub(crate) fn clear_for_test() { + *guard() = None; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + // The holder is process-global; serialise the tests that touch it. + static TEST_LOCK: StdMutex<()> = StdMutex::new(()); + + fn with_clean(f: impl FnOnce() -> T) -> T { + let _l = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + clear_for_test(); + let out = f(); + clear_for_test(); + out + } + + #[test] + fn full_lifecycle() { + with_clean(|| { + assert!(!is_running()); + // ops before init are rejected + assert!(query("hi", QueryRoute::Auto).is_err()); + + init(None).expect("init"); + assert!(!is_running()); + // ops before start are rejected + assert!(process_sensor("accelerometer", vec![1.0], 1).is_err()); + + start().expect("start"); + assert!(is_running()); + + process_sensor("accelerometer", vec![1.0, 2.0, 3.0], 100).expect("process"); + let resp = query("what is happening", QueryRoute::ForceLocal).expect("query"); + assert!(!resp.is_empty()); + + let ctx = neural_context().expect("ctx"); + assert!(ctx.contains("[NEURAL_STATE]")); + + let json = state_json().expect("state json"); + assert!(json.contains("is_active")); + + stop(); + assert!(!is_running()); + }); + } + + #[test] + fn reset_preserves_running_and_config() { + with_clean(|| { + let cfg = r#"{"sample_rate":25.0,"window_size_ms":80,"local_threshold":0.4,"max_response_time_ms":500}"#; + init(Some(cfg)).expect("init"); + start().expect("start"); + assert!(is_running()); + + reset().expect("reset"); + // running flag preserved through the Active->Down->fresh cycle + assert!(is_running()); + // fresh system is usable + query("post reset", QueryRoute::Auto).expect("query after reset"); + }); + } + + #[test] + fn bad_config_json_is_rejected() { + with_clean(|| { + assert!(init(Some("{not valid json")).is_err()); + }); + } +} diff --git a/crates/neurophone-core/src/lib.rs b/crates/neurophone-core/src/lib.rs index 7da7a5d..8ab2251 100644 --- a/crates/neurophone-core/src/lib.rs +++ b/crates/neurophone-core/src/lib.rs @@ -136,6 +136,22 @@ impl fmt::Display for InferenceModel { } } +/// Explicit model routing for a query. +/// +/// `Auto` uses the word-count complexity heuristic against `local_threshold`; +/// `ForceLocal` / `ForceCloud` pin the model regardless of complexity. The JNI +/// surface's `queryLocal` / `queryClaude` need hard routing, which `prefer_local` +/// alone cannot express (it only *permits* local when the query is simple). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum QueryRoute { + /// Heuristic auto-routing (local only when complexity < `local_threshold`). + Auto, + /// Always route to the local Llama, regardless of complexity. + ForceLocal, + /// Always route to cloud Claude, regardless of complexity. + ForceCloud, +} + /// Lifecycle phase markers (typestate). Illegal transitions are compile errors. pub mod phase { /// Constructed but not yet initialised. @@ -228,11 +244,34 @@ impl NeuroSymbolicSystem { }) } - /// Query the system with inference + /// Query the system with inference, auto-routing by complexity. + /// + /// Preserves the historical contract: `prefer_local == false` always routes + /// to cloud; `prefer_local == true` routes local only when the query is + /// simple enough (complexity < `local_threshold`). Implemented on top of + /// [`query_routed`](Self::query_routed). pub fn query( &mut self, message: &str, prefer_local: bool, + ) -> Result { + let route = if prefer_local { + QueryRoute::Auto + } else { + QueryRoute::ForceCloud + }; + self.query_routed(message, route) + } + + /// Query with an explicit [`QueryRoute`], forcing the model when requested. + /// + /// This is the routing primitive the JNI `queryLocal` / `queryClaude` + /// surfaces need: `ForceLocal` / `ForceCloud` pin the model regardless of + /// the complexity heuristic. + pub fn query_routed( + &mut self, + message: &str, + route: QueryRoute, ) -> Result { if message.is_empty() { return Err(NeurophoneError::InferenceError( @@ -243,15 +282,7 @@ impl NeuroSymbolicSystem { let start = Instant::now(); self.query_count += 1; - // Complexity heuristic: count words - let complexity = (message.split_whitespace().count() as f32) / 100.0; - let should_use_local = prefer_local && complexity < self.config.local_threshold; - - let model = if should_use_local { - InferenceModel::LocalLlama - } else { - InferenceModel::CloudClaude - }; + let model = self.select_model(message, route); let response = format!("Response to: {}", message); let latency = start.elapsed().as_millis() as u32; @@ -272,6 +303,23 @@ impl NeuroSymbolicSystem { }) } + /// Select the inference model for a message under a routing policy. + fn select_model(&self, message: &str, route: QueryRoute) -> InferenceModel { + match route { + QueryRoute::ForceLocal => InferenceModel::LocalLlama, + QueryRoute::ForceCloud => InferenceModel::CloudClaude, + QueryRoute::Auto => { + // Complexity heuristic: count words, normalise, compare to threshold. + let complexity = (message.split_whitespace().count() as f32) / 100.0; + if complexity < self.config.local_threshold { + InferenceModel::LocalLlama + } else { + InferenceModel::CloudClaude + } + } + } + } + /// Shut down the system, transitioning `Active -> Down` (terminal). pub fn shutdown(mut self) -> NeuroSymbolicSystem { debug!("Shutting down NeuroPhone"); @@ -307,6 +355,24 @@ impl NeuroSymbolicSystem { pub fn config(&self) -> &SystemConfig { &self.config } + + /// Render a compact neural-context summary string. + /// + /// This is the format the Android service embeds as LLM context. It is + /// composed strictly from real [`SystemState`] fields (no invented salience + /// value): activity, last latency, whether the LSM/ESN reservoirs hold + /// state, and the last processed timestamp. + pub fn get_neural_context(&self) -> String { + let s = &self.state; + format!( + "[NEURAL_STATE] active={} latency_ms={} lsm={} esn={} ts={} [/NEURAL_STATE]", + s.is_active, + s.latency_ms, + s.lsm_state.is_some(), + s.esn_state.is_some(), + s.timestamp_ms + ) + } } #[cfg(test)] @@ -711,4 +777,67 @@ mod tests { let result = system.query("test query", true); assert!(result.is_ok()); } + + // ========== QueryRoute Tests ========== + + #[test] + fn test_query_force_local_overrides_complexity() { + // A long query would auto-route to cloud, but ForceLocal pins it local. + let system = NeuroSymbolicSystem::new(SystemConfig { + local_threshold: 0.1, + ..Default::default() + }) + .expect("system creation"); + let mut system = system.initialize().expect("init"); + + let long = "word ".repeat(100); + let r = system + .query_routed(&long, QueryRoute::ForceLocal) + .expect("query"); + assert_eq!(r.model, InferenceModel::LocalLlama); + } + + #[test] + fn test_query_force_cloud_overrides_simplicity() { + // A trivial query would auto-route local, but ForceCloud pins it cloud. + let system = NeuroSymbolicSystem::new(SystemConfig::default()).expect("system creation"); + let mut system = system.initialize().expect("init"); + + let r = system + .query_routed("hi", QueryRoute::ForceCloud) + .expect("query"); + assert_eq!(r.model, InferenceModel::CloudClaude); + } + + #[test] + fn test_query_delegates_preserving_legacy_behaviour() { + let system = NeuroSymbolicSystem::new(SystemConfig::default()).expect("system creation"); + let mut system = system.initialize().expect("init"); + + // prefer_local == false must always be cloud (historical contract). + let cloud = system.query("hi", false).expect("query"); + assert_eq!(cloud.model, InferenceModel::CloudClaude); + + // prefer_local == true on a simple query is local (Auto). + let local = system.query("hi", true).expect("query"); + assert_eq!(local.model, InferenceModel::LocalLlama); + } + + #[test] + fn test_get_neural_context_format() { + let system = NeuroSymbolicSystem::new(SystemConfig::default()).expect("system creation"); + let mut system = system.initialize().expect("init"); + let event = SensorEvent { + sensor_type: "accelerometer".to_string(), + timestamp_ms: 4242, + values: vec![1.0, 2.0, 3.0], + }; + system.process_sensor_event(&event).ok(); + + let ctx = system.get_neural_context(); + assert!(ctx.starts_with("[NEURAL_STATE]")); + assert!(ctx.ends_with("[/NEURAL_STATE]")); + assert!(ctx.contains("active=true")); + assert!(ctx.contains("ts=4242")); + } }