mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Retire the request-time hint API. PR-B5 splits TOIN into two phases:
1. Observation: TOIN keeps recording compressions/retrievals at runtime,
but `get_recommendation()` is deprecated and now returns None.
2. Publish-then-load: the new `headroom.cli.toin_publish` CLI walks the
on-disk store and emits `recommendations.toml`. The Rust proxy reads
that file once at startup via `transforms::recommendations` and
exposes `get(auth_mode, model, structure_hash) -> Option<&Rec>`.
PR-F3 will wire the loader into the live-zone dispatcher.
Per-tenant aggregation: `_patterns` is now keyed by
`(auth_mode, model_family, sig_hash)` so PAYG/OAuth/subscription tenants
no longer share buckets. Callers that don't supply auth/model land in the
`("unknown", "unknown", sig_hash)` slot. Added `_make_pattern_key` helper
+ updated tests that previously indexed by raw `structure_hash`.
AuthMode is canonical in `transforms::live_zone`; `transforms::recommendations`
re-exports it (no duplicate enum). Live-zone enum gained `Unknown`,
`as_str()`, and `Hash` derive to serve recommendations callers without a
second source of truth.
Why: per-request hint calls coupled output to mutable TOIN state, breaking
prompt-cache stability across runs (P2-27, P5-56). Pulling advice into a
startup-published TOML keeps per-request output deterministic and lets the
deploy pipeline gate publication independently of proxy uptime.
Per-PR-B5 plan: REALIGNMENT/04-phase-B-live-zone.md.
140 lines
4.5 KiB
Rust
140 lines
4.5 KiB
Rust
//! Integration tests for `transforms::recommendations` (PR-B5).
|
|
//!
|
|
//! These pin three guarantees the Rust proxy depends on at startup:
|
|
//!
|
|
//! 1. A well-formed `recommendations.toml` parses into a populated
|
|
//! [`RecommendationStore`] with byte-for-byte the same fields the
|
|
//! Python `headroom.cli.toin_publish` CLI emits.
|
|
//! 2. A missing file degrades to an empty store with no panic — the
|
|
//! proxy must boot even if the publish pipeline is broken.
|
|
//! 3. A malformed file likewise degrades to an empty store, and the
|
|
//! error is surfaced via `tracing::warn!` rather than swallowed.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use headroom_core::transforms::recommendations::{
|
|
AuthMode, RecommendationStore, RecommendationsError,
|
|
};
|
|
|
|
/// Minimal tempdir helper. Project convention is to avoid a
|
|
/// dev-dependency on `tempfile` (see `crates/headroom-parity` for the
|
|
/// matching helper). Cleanup happens on drop.
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn tempdir() -> TempDir {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let p = std::env::temp_dir().join(format!(
|
|
"headroom-recommendations-{nanos}-{:?}",
|
|
std::thread::current().id()
|
|
));
|
|
fs::create_dir_all(&p).unwrap();
|
|
TempDir(p)
|
|
}
|
|
|
|
/// The exact schema emitted by the Python publish CLI. Keeping this
|
|
/// inline in tests prevents accidental schema drift on either side.
|
|
const VALID_TOML: &str = r#"
|
|
[[recommendation]]
|
|
auth_mode = "payg"
|
|
model_family = "claude-3-5"
|
|
structure_hash = "deadbeef00112233"
|
|
strategy_hint = "smart_crusher"
|
|
confidence = 0.87
|
|
observations = 142
|
|
|
|
[[recommendation]]
|
|
auth_mode = "oauth"
|
|
model_family = "gpt-4o"
|
|
structure_hash = "cafebabe44556677"
|
|
strategy_hint = "log_compressor"
|
|
confidence = 0.42
|
|
observations = 60
|
|
"#;
|
|
|
|
#[test]
|
|
fn loads_valid_toml() {
|
|
let dir = tempdir();
|
|
let path = dir.path().join("recommendations.toml");
|
|
fs::write(&path, VALID_TOML).expect("write");
|
|
|
|
let store = RecommendationStore::from_file(&path).expect("parses");
|
|
assert_eq!(store.len(), 2);
|
|
|
|
let payg = store
|
|
.lookup(AuthMode::Payg, "claude-3-5", "deadbeef00112233")
|
|
.expect("payg row");
|
|
assert_eq!(payg.strategy_hint, "smart_crusher");
|
|
assert!((payg.confidence - 0.87).abs() < 1e-9);
|
|
assert_eq!(payg.observations, 142);
|
|
|
|
let oauth = store
|
|
.lookup(AuthMode::OAuth, "gpt-4o", "cafebabe44556677")
|
|
.expect("oauth row");
|
|
assert_eq!(oauth.strategy_hint, "log_compressor");
|
|
assert_eq!(oauth.observations, 60);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_yields_empty_recommendations() {
|
|
let dir = tempdir();
|
|
let path = dir.path().join("does_not_exist.toml");
|
|
|
|
// `from_file` surfaces Missing as a typed error.
|
|
let err = RecommendationStore::from_file(&path).unwrap_err();
|
|
assert!(matches!(err, RecommendationsError::Missing(_)));
|
|
|
|
// `load_or_empty` is the production entry point and degrades
|
|
// gracefully — no panic, empty store.
|
|
let store = RecommendationStore::load_or_empty(&path);
|
|
assert!(store.is_empty());
|
|
assert!(store.lookup(AuthMode::Payg, "claude-3-5", "any").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_toml_logs_and_yields_empty() {
|
|
let dir = tempdir();
|
|
let path = dir.path().join("recommendations.toml");
|
|
// Real-world breakage: missing closing brace + unquoted value.
|
|
fs::write(&path, "this is [[ definitely not valid\nrubbish = \n").expect("write");
|
|
|
|
// The typed loader returns a Parse error.
|
|
let err = RecommendationStore::from_file(&path).unwrap_err();
|
|
assert!(
|
|
matches!(err, RecommendationsError::Parse(_)),
|
|
"expected Parse, got {err:?}"
|
|
);
|
|
|
|
// The production loader returns an empty store — proxy still
|
|
// boots; ops alert on the structured `tracing::warn!` event.
|
|
let store = RecommendationStore::load_or_empty(&path);
|
|
assert!(store.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_recommendation_array_is_valid() {
|
|
// A publish run with no eligible slices writes a header-only file.
|
|
// It must parse cleanly to an empty store, not surface as an error.
|
|
let dir = tempdir();
|
|
let path = dir.path().join("recommendations.toml");
|
|
fs::write(&path, "# Auto-generated by toin_publish\n").expect("write");
|
|
|
|
let store = RecommendationStore::from_file(&path).expect("parses");
|
|
assert!(store.is_empty());
|
|
}
|