open-standards/aac/aac-rs/crates/aac-conformance/tests/adaptive.rs
Transaction Science edcf02b350 feat(access-core, aac-core): Bayesian selection that never moves a cell, and prediction in 43 languages
Selection (access-core; SPEC §5.4, proposed): a prior over the options on the
board — usage frequency now, the Laya decision model later — times the
person's own switch-timing likelihood, after Nomon (Broderick & MacKay):
- press_timing.rs learns a damped kernel-density estimate of each press's
  offset from the intended highlight, from a population prior scaled to the
  person's scan interval, and only from selections the person keeps: presses
  are staged per attempt, dropped when abandoned or undone (including an Undo
  reached by scanning), and learned only after three newer selections.
- selection.rs computes the posterior (late presses explained as late, a
  2% lapse term), selects at 99:1 odds with the prior capped so presses must
  favour the choice 4:1, and otherwise keeps scanning — a row gets up to two
  extra passes. Without opt-in, or before 10 kept presses, it behaves exactly
  like the conventional scanner and only measures.
- scan_order.rs orders rows, groups and cells by prior mass (fewest expected
  steps) without moving any; options keep stable position-order ids, and a
  new order applies from the next pass. dwell.rs weights per-cell dwell from
  0.8x to 1.5x within hard bounds. Every adaptation is opt-in and previewable.
Conformance C8–C11 (order never moves a position, opt-in required, dwell
bounds, selection follows presses): 370/370 with the original 236.

Prediction (aac-core): per-language packs — word frequencies, word pairs,
sentence starts and a letter model, front-coded and CRC-checked — built
reproducibly by tools/lang/build_packs.py from pinned wordfreq 3.2.0
(CC-BY-SA-4.0), FrequencyWords 2018 (CC-BY-SA-4.0) and Common Voice sentences
(CC0), into data/lang/ (43 of 45 languages; gu and kn have no open source
large enough and report "adaptive only"). PredictionEngine blends a pack with
what the person has said (hierarchical Dirichlet smoothing): next word,
completion, and letter prediction for letter scanning, deterministically.
A per-language pair filter exists; its lists still need curating before the
app shows next-word suggestions to children.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 11:28:26 -04:00

93 lines
3.6 KiB
Rust

//! The published adaptive vector is what the oracles produce, the reference
//! engine passes it, and the harness notices when it is tampered with.
use std::path::PathBuf;
use aac_conformance::{Report, adaptive, default_vector_dir, load_vector, run_with_vectors};
use serde_json::Value;
fn published() -> Value {
load_vector(&default_vector_dir(), "adaptive.json").expect("conformance/v1/adaptive.json")
}
/// A copy of the published vectors with `adaptive.json` replaced by `doc`.
fn with_adaptive(doc: &Value, tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("aac-conformance-adaptive-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for f in ["cascade.json", "scan_rate.json", "dwell.json"] {
std::fs::copy(default_vector_dir().join(f), dir.join(f)).unwrap();
}
std::fs::write(dir.join("adaptive.json"), serde_json::to_vec_pretty(doc).unwrap()).unwrap();
dir
}
fn failures(r: &Report, prefix: &str) -> Vec<String> {
r.lines.iter().filter(|l| l.contains("FAIL") && l.contains(prefix)).cloned().collect()
}
#[test]
fn the_published_vector_is_the_oracles_output() {
// Byte for byte, as gen-vectors writes it (serde_json's default parser is
// not correctly rounded, so comparing parsed floats would be unsound).
let on_disk = std::fs::read_to_string(default_vector_dir().join("adaptive.json")).unwrap();
let generated = serde_json::to_string_pretty(&adaptive::adaptive_vector()).unwrap() + "\n";
assert!(on_disk == generated, "run gen-vectors: conformance/v1/adaptive.json is stale");
}
#[test]
fn the_reference_engine_passes_c8_to_c11() {
let r = run_with_vectors(&default_vector_dir());
for c in ["C8", "C9", "C10", "C11"] {
assert!(failures(&r, c).is_empty(), "{:?}", failures(&r, c));
assert!(r.lines.iter().any(|l| l.contains(&format!("PASS {c} "))), "{c} ran no checks");
}
}
#[test]
fn a_tampered_order_fails_c8() {
let mut doc = published();
let units = doc["order_cases"][0]["expected_units"].as_array_mut().unwrap();
units.swap(0, 1);
let r = run_with_vectors(&with_adaptive(&doc, "order"));
assert!(!failures(&r, "C8").is_empty());
}
#[test]
fn a_tampered_decision_fails_c11() {
let mut doc = published();
doc["selection_cases"][0]["expected_decision"]["option"] = 2.into();
let r = run_with_vectors(&with_adaptive(&doc, "decision"));
assert!(!failures(&r, "C11").is_empty());
}
#[test]
fn a_vector_claiming_adaptation_is_on_by_default_fails_c9() {
let mut doc = published();
doc["rule"]["adaptive_order_default"] = true.into();
let r = run_with_vectors(&with_adaptive(&doc, "default"));
assert!(!failures(&r, "C9").is_empty());
}
#[test]
fn a_dwell_outside_the_bounds_fails_c10() {
let mut doc = published();
doc["dwell_cases"][0]["expected_dwell_ms"][0][2] = 500.into(); // below 640
let r = run_with_vectors(&with_adaptive(&doc, "dwell"));
assert!(!failures(&r, "C10").is_empty());
}
#[test]
fn a_vector_publishing_another_offset_window_fails_c11() {
let mut doc = published();
doc["rule"]["selection"]["offset_window_intervals"] = serde_json::json!([-0.5, 10.0]);
let r = run_with_vectors(&with_adaptive(&doc, "window"));
assert!(!failures(&r, "C11").is_empty());
}
#[test]
fn proposed_checks_are_labelled_and_counted() {
let r = run_with_vectors(&default_vector_dir());
let labelled = r.lines.iter().filter(|l| l.contains("[proposed]")).count();
assert!(labelled > 0 && labelled as u32 == r.proposed, "{labelled} labelled, {} counted", r.proposed);
assert!(r.lines.iter().filter(|l| l.contains(" C5 ")).all(|l| !l.contains("[proposed]")));
}