diff --git a/contracts/golden/intents.json b/contracts/golden/intents.json new file mode 100644 index 0000000..7d69355 --- /dev/null +++ b/contracts/golden/intents.json @@ -0,0 +1,37 @@ +{ + "comment": "parse 意图词汇单一来源(issue #98;含全角归一/清屏同义词/否定后置不抑制边界)。Rust parse()(crates/oriera-agent/src/lib.rs)与浏览器 kernel parseIntent()(examples/11-ai-cafe/engine/kernel.js)双端共读此文件做镜像断言——改任何一侧的解析行为(加意图、改否定语义、改参数提取)必须同步更新此文件,否则双端 CI 同红。expect.kind 词汇:spawn/clear/gravity/teleport/count/speed/unknown。", + "cases": [ + { "input": "spawn 40 particles", "expect": { "kind": "spawn", "n": 40 } }, + { "input": "生成 50 个粒子", "expect": { "kind": "spawn", "n": 50 } }, + { "input": "放 20 个", "expect": { "kind": "spawn", "n": 20 } }, + { "input": "生成3个", "expect": { "kind": "spawn", "n": 3 } }, + { "input": "clear the world", "expect": { "kind": "clear" } }, + { "input": "清空", "expect": { "kind": "clear" } }, + { "input": "清屏", "expect": { "kind": "clear" } }, + { "input": "清空,但别关灯", "expect": { "kind": "clear" } }, + { "input": "放5个", "expect": { "kind": "spawn", "n": 5 } }, + { "input": "生成10个", "expect": { "kind": "spawn", "n": 10 } }, + { "input": "set gravity to 1.6", "expect": { "kind": "gravity", "g": 1.6 } }, + { "input": "set gravity to 10", "expect": { "kind": "gravity", "g": 10 } }, + { "input": "gravity 9.8", "expect": { "kind": "gravity", "g": 9.8 } }, + { "input": "重力 9.8", "expect": { "kind": "gravity", "g": 9.8 } }, + { "input": "重力 9", "expect": { "kind": "gravity", "g": 9 } }, + { "input": "teleport 100 -25", "expect": { "kind": "teleport", "dx": 100, "dy": -25 } }, + { "input": "teleport to 100 200", "expect": { "kind": "teleport", "dx": 100, "dy": 200 } }, + { "input": "传送到 50,-25", "expect": { "kind": "teleport", "dx": 50, "dy": -25 } }, + { "input": "how many", "expect": { "kind": "count" } }, + { "input": "how many entities", "expect": { "kind": "count" } }, + { "input": "现在有多少个", "expect": { "kind": "count" } }, + { "input": "有多少个", "expect": { "kind": "count" } }, + { "input": "speed 2x", "expect": { "kind": "speed", "k": 2 } }, + { "input": "速度 1.5 倍", "expect": { "kind": "speed", "k": 1.5 } }, + { "input": "倍速 0.5", "expect": { "kind": "speed", "k": 0.5 } }, + { "input": "别清空", "expect": { "kind": "unknown", "text": "别清空" } }, + { "input": "不要生成新的", "expect": { "kind": "unknown", "text": "不要生成新的" } }, + { "input": "不要 spawn 5", "expect": { "kind": "unknown", "text": "不要 spawn 5" } }, + { "input": "do not spawn", "expect": { "kind": "unknown", "text": "do not spawn" } }, + { "input": "hello there", "expect": { "kind": "unknown", "text": "hello there" } }, + { "input": "给我讲个故事", "expect": { "kind": "unknown", "text": "给我讲个故事" } }, + { "input": "teleport to the moon", "expect": { "kind": "unknown", "text": "teleport to the moon" } } + ] +} diff --git a/contracts/golden/presets.json b/contracts/golden/presets.json new file mode 100644 index 0000000..3a5c829 --- /dev/null +++ b/contracts/golden/presets.json @@ -0,0 +1,50 @@ +{ + "comment": "L7 provider 预设表单一来源(issue #98)。Rust PROVIDER_PRESETS(crates/oriera-infer/src/provider.rs)与浏览器 kernel PROVIDER_PRESETS(examples/11-ai-cafe/engine/kernel.js)双端共读此文件做逐行对照——新增/修改/删除预设行必须同步更新此文件。caps 五字段恒显式(contextWindow 为 token 数或 null);price 为 null 或四字段对象(µ 货币 / 每百万 tokens);kind 词汇:rule/zhipu/openai-compat/anthropic。", + "rows": [ + { "id": "rule", "label": "离线规则引擎", "kind": "rule", "model": "rule-v1", "base": "", "local": true, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "xlite", "label": "XLite 潜元网关", "kind": "openai-compat", "model": "gpt-4o-mini", "base": "https://xlite.dezai.com/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": true, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "zhipu", "label": "智谱 GLM", "kind": "zhipu", "model": "glm-4-flash", "base": "https://open.bigmodel.cn/api/paas/v4", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": true, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "openai", "label": "OpenAI", "kind": "openai-compat", "model": "gpt-4o-mini", "base": "https://api.openai.com/v1", "local": false, + "caps": { "contextWindow": 128000, "structuredOutput": true, "tools": true, "imageInput": true, "promptCache": true }, + "price": { "currency": "USD", "promptMicroPerM": 150000, "completionMicroPerM": 600000, "cachedMicroPerM": 75000 } }, + { "id": "qwen", "label": "Qwen 通义", "kind": "openai-compat", "model": "qwen-plus", "base": "https://dashscope.aliyuncs.com/compatible-mode/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "stepfun", "label": "阶跃 Step", "kind": "openai-compat", "model": "step-3.7-flash", "base": "https://api.stepfun.com/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "deepseek", "label": "DeepSeek", "kind": "openai-compat", "model": "deepseek-chat", "base": "https://api.deepseek.com/v1", "local": false, + "caps": { "contextWindow": 1000000, "structuredOutput": true, "tools": true, "imageInput": true, "promptCache": true }, + "price": { "currency": "USD", "promptMicroPerM": 150000, "completionMicroPerM": 600000, "cachedMicroPerM": 3000 } }, + { "id": "moonshot", "label": "Moonshot Kimi", "kind": "openai-compat", "model": "moonshot-v1-8k", "base": "https://api.moonshot.cn/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "groq", "label": "Groq", "kind": "openai-compat", "model": "llama-3.1-8b-instant", "base": "https://api.groq.com/openai/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "openrouter", "label": "OpenRouter", "kind": "openai-compat", "model": "openrouter/auto", "base": "https://openrouter.ai/api/v1", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "anthropic", "label": "Anthropic", "kind": "anthropic", "model": "claude-3-5-haiku-latest", "base": "https://api.anthropic.com/v1", "local": false, + "caps": { "contextWindow": 200000, "structuredOutput": false, "tools": true, "imageInput": true, "promptCache": true }, + "price": null }, + { "id": "ollama", "label": "Ollama 本地", "kind": "openai-compat", "model": "qwen3:8b", "base": "http://127.0.0.1:11434/v1", "local": true, + "caps": { "contextWindow": null, "structuredOutput": true, "tools": true, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "vllm", "label": "vLLM 本地", "kind": "openai-compat", "model": "Qwen/Qwen3-8B", "base": "http://127.0.0.1:8000/v1", "local": true, + "caps": { "contextWindow": null, "structuredOutput": true, "tools": true, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "llamacpp", "label": "llama.cpp 本地", "kind": "openai-compat", "model": "glm-4-9b-chat", "base": "http://127.0.0.1:8080/v1", "local": true, + "caps": { "contextWindow": null, "structuredOutput": true, "tools": false, "imageInput": false, "promptCache": false }, + "price": null }, + { "id": "lmstudio", "label": "LM Studio 本地", "kind": "openai-compat", "model": "minicpm-v", "base": "http://127.0.0.1:1234/v1", "local": true, + "caps": { "contextWindow": null, "structuredOutput": true, "tools": false, "imageInput": false, "promptCache": false }, + "price": null } + ] +} diff --git a/crates/oriera-agent/Cargo.toml b/crates/oriera-agent/Cargo.toml index 9a89ffe..19b92b2 100644 --- a/crates/oriera-agent/Cargo.toml +++ b/crates/oriera-agent/Cargo.toml @@ -30,6 +30,8 @@ rigid = ["oriera-subsys/rigid"] [dev-dependencies] oriera-render = { path = "../oriera-render" } +# golden fixture tests (#98) read contracts/golden/*.json on every test leg +serde_json = { workspace = true } oriera-subsys = { path = "../oriera-subsys" } [[example]] diff --git a/crates/oriera-agent/src/lib.rs b/crates/oriera-agent/src/lib.rs index ce3a3ad..77cbbe7 100644 --- a/crates/oriera-agent/src/lib.rs +++ b/crates/oriera-agent/src/lib.rs @@ -50,7 +50,19 @@ use oriera_infer::Command; /// the intent keyword suppresses execution ("别清空" is not Clear); /// a negation after the keyword does not ("清空,但别关灯" still Clear). pub fn parse(input: &str) -> Command { - let lower = input.to_lowercase(); + // 全角数字归一(对镜 kernel v2.1:中文输入法「放5个」常见,评审 P1—— + // 此前只有 JS 归一,全角输入双端分叉且 fixture 无法表达该差异)。 + let lower: String = input + .to_lowercase() + .chars() + .map(|c| { + if ('\u{ff10}'..='\u{ff19}').contains(&c) { + char::from_u32(c as u32 - 0xfee0).unwrap_or(c) + } else { + c + } + }) + .collect(); if negation_precedes_intent(&lower) { return Command::Unknown(input.to_string()); } @@ -211,4 +223,65 @@ mod tests { assert_eq!(parse("清空,但别关灯"), Command::Clear); assert_eq!(parse("spawn 5, never mind the rest"), Command::Spawn(5)); } + + /// #98 golden fixture: the intent vocabulary has ONE source + /// (`contracts/golden/intents.json`), read by this test and by + /// `kernel.test.mjs` on the JS side. Changing either parser without + /// updating the fixture now breaks CI on both ends instead of + /// drifting silently. + #[test] + fn parse_matches_golden_fixture() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/golden/intents.json" + ); + let fx: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).expect("read contracts/golden/intents.json"), + ) + .expect("parse intents.json"); + let cases = fx["cases"].as_array().expect("cases array"); + assert!(!cases.is_empty(), "fixture must not be empty"); + for case in cases { + let input = case["input"].as_str().expect("case.input"); + let want = &case["expect"]; + let kind = want["kind"].as_str().expect("expect.kind"); + let got = parse(input); + match kind { + "spawn" => assert_eq!( + got, + Command::Spawn(want["n"].as_f64().expect("n").round() as usize), + "input: {input}" + ), + "clear" => assert_eq!(got, Command::Clear, "input: {input}"), + "gravity" => assert_eq!( + got, + Command::SetGravity(want["g"].as_f64().expect("g") as f32), + "input: {input}" + ), + "teleport" => assert_eq!( + got, + Command::Teleport( + want["dx"].as_f64().expect("dx") as f32, + want["dy"].as_f64().expect("dy") as f32 + ), + "input: {input}" + ), + "count" => assert_eq!(got, Command::Count, "input: {input}"), + "speed" => assert_eq!( + got, + Command::SetSpeed(want["k"].as_f64().expect("k") as f32), + "input: {input}" + ), + "unknown" => match got { + Command::Unknown(s) => assert_eq!( + s, + want["text"].as_str().expect("expect.text"), + "input: {input}" + ), + other => panic!("input {input}: expected Unknown, got {other:?}"), + }, + other => panic!("fixture has unknown kind: {other}"), + } + } + } } diff --git a/crates/oriera-infer/Cargo.toml b/crates/oriera-infer/Cargo.toml index caa8697..7542cf3 100644 --- a/crates/oriera-infer/Cargo.toml +++ b/crates/oriera-infer/Cargo.toml @@ -28,3 +28,7 @@ deepseek-recipe-core = { workspace = true, optional = true } [features] default = [] native-http = ["dep:ureq", "dep:serde_json", "dep:deepseek-recipe-core"] + +[dev-dependencies] +# golden fixture test (#98) reads contracts/golden/presets.json on every test leg +serde_json = { workspace = true } diff --git a/crates/oriera-infer/src/http.rs b/crates/oriera-infer/src/http.rs index 8c9ed30..4154f30 100644 --- a/crates/oriera-infer/src/http.rs +++ b/crates/oriera-infer/src/http.rs @@ -541,7 +541,10 @@ fn is_length_truncated_empty(raw: &str) -> bool { let Ok(v) = serde_json::from_str::(raw) else { return false; }; - if v.pointer("/choices/0/finish_reason").and_then(Value::as_str) != Some("length") { + if v.pointer("/choices/0/finish_reason") + .and_then(Value::as_str) + != Some("length") + { return false; } v.pointer("/choices/0/message/content") @@ -1460,7 +1463,10 @@ mod tests { ); assert_eq!(p.complete(&[ChatMessage::user("hi")]).unwrap(), "好了"); let reqs = mock.requests(); - assert!(reqs[0].contains(r#""temperature":0.85"#), "first sends default"); + assert!( + reqs[0].contains(r#""temperature":0.85"#), + "first sends default" + ); assert!( reqs[1].contains(r#""temperature":1.0"#), "heal round sends 1.0: {}", @@ -1521,7 +1527,11 @@ mod tests { Err(InferError::Status { code: 400, .. }) => {} other => panic!("expected Status(400), got {other:?}"), } - assert_eq!(mock.requests().len(), 1, "no heal round, no transport retry"); + assert_eq!( + mock.requests().len(), + 1, + "no heal round, no transport retry" + ); } // ---- RFC-3 工具调用 + SSE 流式 ---- diff --git a/crates/oriera-infer/src/lib.rs b/crates/oriera-infer/src/lib.rs index 53310e8..e81d497 100644 --- a/crates/oriera-infer/src/lib.rs +++ b/crates/oriera-infer/src/lib.rs @@ -123,10 +123,13 @@ pub struct CostMeter { /// offline rule row and unknown labels read unpriced. pub fn price_for_label(label: &str) -> Option<&'static ModelPrice> { let (kind, model) = label.split_once(':')?; + // kind:model 标签可能撞行(xlite 网关转发 openai 同名模型):首个 + // *有登记价* 的行胜出——转发网关未登记价时按源厂牌价记账是合理近似; + // 全部无价才返回 None(绝不猜价)。 PROVIDER_PRESETS .iter() - .find(|p| p.kind.id() == kind && p.model == model) - .and_then(|p| p.price.as_ref()) + .filter(|p| p.kind.id() == kind && p.model == model) + .find_map(|p| p.price.as_ref()) } impl CostMeter { diff --git a/crates/oriera-infer/src/provider.rs b/crates/oriera-infer/src/provider.rs index 8732fb5..80c518f 100644 --- a/crates/oriera-infer/src/provider.rs +++ b/crates/oriera-infer/src/provider.rs @@ -919,6 +919,21 @@ pub const PROVIDER_PRESETS: &[ProviderPreset] = &[ price: None, }, // ── cloud vendors (reserved API interfaces; key in the shell) ── + // xlite(上游 2f93226 在 kernel 侧先行;此处补齐 Rust 行——presets.json + // golden 门禁(#98)在 rebase 时抓到的 15↔14 双端漂移) + ProviderPreset { + id: "xlite", + label: "XLite 潜元网关", + kind: ProviderKind::OpenAiCompat, + model: "gpt-4o-mini", + base_url: "https://xlite.dezai.com/v1", + local: false, + caps: ModelCaps { + tools: true, + ..ModelCaps::UNREGISTERED + }, + price: None, // 网关转售口径,牌价随上游,待 B8 核价 + }, ProviderPreset { id: "zhipu", label: "智谱 GLM", @@ -1371,7 +1386,9 @@ mod preset_tests { "geek row {id} missing" ); } - assert_eq!(PROVIDER_PRESETS.len(), 14); + // 行数单一来源是 contracts/golden/presets.json(presets_match_golden_fixture + // 逐行对照);这里只防「表与镜像测试失联」的低级回归。 + assert_eq!(PROVIDER_PRESETS.len(), 15); } #[test] @@ -1451,4 +1468,137 @@ mod preset_tests { assert!(row.price.is_none(), "{} local must be unpriced", row.id); } } + + /// One golden row, normalized to the `contracts/golden/presets.json` + /// shape (camelCase; caps always explicit; price null or four fields). + #[cfg(test)] + fn preset_json(p: &ProviderPreset) -> serde_json::Value { + serde_json::json!({ + "id": p.id, + "label": p.label, + "kind": p.kind.id(), + "model": p.model, + "base": p.base_url, + "local": p.local, + "caps": { + "contextWindow": p.caps.context_window, + "structuredOutput": p.caps.structured_output, + "tools": p.caps.tools, + "imageInput": p.caps.image_input, + "promptCache": p.caps.prompt_cache, + }, + "price": p.price.map(|pr| serde_json::json!({ + "currency": pr.currency, + "promptMicroPerM": pr.prompt_micro_per_m, + "completionMicroPerM": pr.completion_micro_per_m, + "cachedMicroPerM": pr.cached_micro_per_m, + })), + }) + } + + /// #98 golden fixture: the preset table has ONE source + /// (`contracts/golden/presets.json`), read by this test and by + /// `kernel.test.mjs`. Adding/editing a preset row without updating + /// the fixture now breaks CI on both ends instead of drifting. + #[test] + fn presets_match_golden_fixture() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/golden/presets.json" + ); + let fx: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).expect("read contracts/golden/presets.json"), + ) + .expect("parse presets.json"); + let rows = fx["rows"].as_array().expect("rows array"); + assert_eq!( + rows.len(), + PROVIDER_PRESETS.len(), + "fixture row count out of sync with the Rust table" + ); + for (p, want) in PROVIDER_PRESETS.iter().zip(rows) { + assert_eq!(preset_json(p), *want, "preset row mismatch: id={}", p.id); + } + } +} + +/// ============ B8 model-acceptance gate (issue #99) ============ +/// +/// The promotion gate a backend must clear before it earns a +/// "recommended" slot: ONE offline smoke entry over the rule backend +/// (the reference implementation), so the gate has a single name to +/// point at. The deep per-leg coverage is not duplicated here — it is +/// named in `docs/STABILITY.md` §B8 (structured-output wire legs, +/// tool round-trips, cache-dialect parsing, fallback-chain policies). +/// Registration integrity of preset rows is pinned by +/// [`presets_match_golden_fixture`] above (#98). +#[cfg(test)] +mod b8_gate { + use super::*; + use crate::health::FallbackProvider; + use std::sync::Arc; + + /// A provider that always answers 500 — the failing head of the + /// fallback drill. + struct AlwaysDown; + impl ModelProvider for AlwaysDown { + fn id(&self) -> &str { + "always-down" + } + fn complete(&self, _m: &[ChatMessage]) -> Result { + Err(InferError::Status { + code: 500, + detail: "b8 drill".into(), + }) + } + } + + fn msgs() -> Vec { + vec![ChatMessage::user("介绍你自己")] + } + + #[test] + fn b8_gate_smoke_reference_backend_clears_all_legs() { + let rule = RuleProvider::new(); + + // Leg 1 — determinism floor: same input, same output (Recorded + // promise is testable offline; a promoted model must not be + // worse than the backend it stands in for). + let a = rule.complete(&msgs()).expect("leg1 first"); + let b = rule.complete(&msgs()).expect("leg1 second"); + assert_eq!(a, b, "rule backend must be deterministic"); + + // Leg 2 — honest usage accounting: a promoted backend must be + // measurable (CostMeter has nothing to price without this). + let u = rule.last_usage(); + assert!( + u.prompt > 0 && u.completion > 0, + "usage must be reported after a call: {u:?}" + ); + + // Leg 3 — streaming honesty: chunks always concatenate to the + // complete() reply, even when the backend does not really + // stream (len == 1 is the honest no-stream signal). + let chunks = rule.complete_stream(&msgs()).expect("leg3"); + assert_eq!(chunks.concat(), a, "stream must concat to complete"); + + // Leg 4 — resilience: a broken head degrades to the rule bank + // visibly, and the health report names the culprit. + let fb = FallbackProvider::new(vec![ + Arc::new(AlwaysDown), + Arc::new(RuleProvider::degraded("always-down")), + ]); + let out = fb.complete(&msgs()).expect("leg4 fallback"); + assert!(!out.is_empty(), "fallback must still answer"); + let report = fb.health_report(); + // The report must not merely NAME the culprit — it must have + // counted against it (a record()-no-op regression would pass a + // name-only check; review P2). + assert!( + report + .iter() + .any(|(id, _, fails)| *id == "always-down" && *fails >= 1), + "health report must record the failure, not just list the chain" + ); + } } diff --git a/crates/oriera-subsys/src/rigid.rs b/crates/oriera-subsys/src/rigid.rs index c3b70ca..a4dcc58 100644 --- a/crates/oriera-subsys/src/rigid.rs +++ b/crates/oriera-subsys/src/rigid.rs @@ -59,6 +59,10 @@ pub struct RigidBodySystem { /// Velocity we last wrote back, per entity — the external-write /// detector of step 2. last_vel: BTreeMap, + /// Step-4 scratch table, rebuilt each tick into the same allocation + /// (`clear()` keeps capacity): `EntKey → (x, y, vx, vy)` clamped + /// translation/velocity to flush into the columns in ONE pass. + writeback: BTreeMap, walls: Vec, } @@ -81,6 +85,7 @@ impl RigidBodySystem { soft_bodies: SoftBodySet::new(), map: BTreeMap::new(), last_vel: BTreeMap::new(), + writeback: BTreeMap::new(), walls: Vec::new(), }; sys.build_walls(); @@ -210,7 +215,14 @@ impl System for RigidBodySystem { &(), ); - // 4. Write back. + // 4. Write back — one column pass with a key lookup (#100). + // The per-entity `for_each2_mut` scan here was O(n²): a full + // column walk to find each entity's own row. Build a scratch + // table (same allocation every tick), then flush all bodies in + // a single pass. Per-entity writes depend only on that entity's + // body state, so batching preserves the write order semantics — + // and `two_runs_are_bit_identical` pins the bit-level contract. + self.writeback.clear(); for (k, _, _) in &live { let Some(&h) = self.map.get(k) else { continue }; let Some(b) = self.bodies.get(h) else { @@ -223,16 +235,19 @@ impl System for RigidBodySystem { let (bw, bh) = self.bounds; let x = t.x.clamp(0.0, bw); let y = t.y.clamp(0.0, bh); - let key_of = |e: oriera_ecs::Entity| (e.index, e.generation); + self.writeback.insert(*k, (x, y, lv.x, lv.y)); + self.last_vel.insert(*k, (lv.x, lv.y)); + } + if !self.writeback.is_empty() { + let wb = &self.writeback; world.for_each2_mut::(|e, p, v| { - if key_of(e) == *k { + if let Some(&(x, y, vx, vy)) = wb.get(&(e.index, e.generation)) { p.x = x; p.y = y; - v.vx = lv.x; - v.vy = lv.y; + v.vx = vx; + v.vy = vy; } }); - self.last_vel.insert(*k, (lv.x, lv.y)); } } } @@ -387,6 +402,37 @@ mod tests { assert_eq!(b1, b2, "per-entity bits differ across runs"); } + /// #100 regression: the batched single-pass write-back must reach + /// EVERY body. Linear motion, no contacts at all (body spacing AND + /// wall clearance exceed 2·radius — grid origin 9 keeps the r=8 + /// balls off the walls), so each row must advance exactly `vx·dt` — + /// a missed write leaves that body at dx = 0. + #[test] + fn thousand_bodies_all_receive_their_writeback() { + let mut world = World::new(); + let mut bodies = Vec::new(); + for i in 0..1000 { + let e = world.spawn(); + let x0 = 9.0 + (i % 25) as f32 * 17.0; + let y0 = 9.0 + (i / 25) as f32 * 17.0; + world.insert(e, Position { x: x0, y: y0 }); + world.insert(e, Velocity { vx: 10.0, vy: 0.0 }); + bodies.push((e, x0)); + } + let mut sys = RigidBodySystem::new(430.0, 700.0); + sys.run(&ctx(1.0 / 60.0), &mut world); + let dt = 1.0f32 / 60.0; + for &(e, x0) in &bodies { + let p = world.get::(e).unwrap(); + assert!( + (p.x - x0 - 10.0 * dt).abs() < 1e-3, + "body missed its writeback: expected x≈{}, got {}", + x0 + 10.0 * dt, + p.x + ); + } + } + #[test] fn despawned_entities_leave_no_ghost_bodies() { let mut world = World::new(); diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 43426b9..2026776 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -42,6 +42,28 @@ - 关键决策全部留痕 ADR/RFC,新维护者可从文档重建上下文; - BDFL 连续 6 周不可用时,Core Team 按 GOVERNANCE 接管发版(见休眠公告条款)。 +### 5. B8 模型验收门禁(issue #99) + +模型或预设行升「推荐位」前必须通过的行为套件,入口是**一个**显式命名的测试: + +- **门禁入口**:`provider::b8_gate::b8_gate_smoke_reference_backend_clears_all_legs` + (crates/oriera-infer/src/provider.rs,离线零网络,进 CI workspace 腿)—— + 确定性底线 / usage 计量底线 / 流式拼接诚实 / 降级链韧性四腿聚合冒烟; +- **深度覆盖**(不在此重复,由以下常驻测试指认):结构化输出线格式与 + fail-fast(`invalid_schema_fails_before_any_network_or_key_spend`、 + `schema_reaches_the_wire_as_json_schema_and_supersedes_json_mode`)、 + 工具往返(`openai_tool_calls_round_trip`、`anthropic_tool_use_round_trip` + 及多轮线上测试)、缓存方言(`deepseek_style_cache_hits…`、 + `anthropic_cache_read_parses_and_a_lying_vendor_is_clamped` 等三方言)、 + 降级链政策(`health::tests` 八条,含凭据连击开闸); +- **预设表登记完整性**:预设单一来源 `contracts/golden/presets.json`(#98), + Rust 与浏览器内核逐行对照;新增/改行不更新 fixture 即双端 CI 同红; + 能力与牌价登记语义由 `caps_and_prices_are_registered_where_claimed` 锁定。 + +裁定记录(2026-10-22):行为四腿在 #92–#95 已高度覆盖,B8 的实际增量是 +「显式门禁入口 + 登记单一来源」,不再新开 CI 独立腿(native-http 腿已常驻 +全部深度测试),避免 CI 时长膨胀。 + ## 三、已知不稳定边界(诚实清单) 以下能力**尚未达到上述承诺**,使用前须知: diff --git a/examples/11-ai-cafe/engine/kernel.test.mjs b/examples/11-ai-cafe/engine/kernel.test.mjs index 1680e8c..d2921a8 100644 --- a/examples/11-ai-cafe/engine/kernel.test.mjs +++ b/examples/11-ai-cafe/engine/kernel.test.mjs @@ -1,6 +1,7 @@ // kernel v2 测试(node --test 直测浏览器内核;kernel 已对无 localStorage // 环境静默降级)。运行:node --test examples/11-ai-cafe/engine/kernel.test.mjs import test from "node:test"; +import { readFileSync } from "node:fs"; import assert from "node:assert/strict"; import { MemoryStore, HttpChatProvider, parseIntent, CommandQueue, @@ -10,6 +11,61 @@ import { const fitBudgetJS = fitBudget; // 显式别名:断言里读得出这是对镜腿 +/* ---- golden fixture 单一来源(#98):与 Rust 双端共读 contracts/golden/*.json。 + 改任一侧解析/预设而不更新 fixture,双端 CI 同红——漂移从此有闸门。 ---- */ +const INTENT_FIXTURE = new URL("../../../contracts/golden/intents.json", import.meta.url); +const PRESET_FIXTURE = new URL("../../../contracts/golden/presets.json", import.meta.url); + +test("parse 镜像:golden fixture 共读(intents.json 单一来源)", () => { + const { cases } = JSON.parse(readFileSync(INTENT_FIXTURE, "utf8")); + assert.ok(cases.length >= 20, "fixture must not be emptied"); + for (const { input, expect } of cases) { + const got = parseIntent(input); + assert.equal(got.kind, expect.kind, `input: ${input}`); + if (expect.kind === "spawn") assert.equal(got.n, expect.n, `input: ${input}`); + if (expect.kind === "gravity") assert.equal(got.g, expect.g, `input: ${input}`); + if (expect.kind === "teleport") { + assert.equal(got.dx, expect.dx, `input: ${input}`); + assert.equal(got.dy, expect.dy, `input: ${input}`); + } + if (expect.kind === "speed") assert.equal(got.k, expect.k, `input: ${input}`); + if (expect.kind === "unknown") assert.equal(got.text, expect.text, `input: ${input}`); + } +}); + +test("预设表镜像:golden fixture 共读(presets.json 单一来源)", () => { + const { rows } = JSON.parse(readFileSync(PRESET_FIXTURE, "utf8")); + assert.equal(rows.length, Object.keys(PROVIDER_PRESETS).length, "row count out of sync"); + /* kind 推导是 JS 运行时事实(HttpChatProvider 仅对 anthropic 分方言, + zhipu/rule 语义归位对齐 Rust 词汇表);这是刻意保留的第二映射点—— + 若 Rust 侧行的 ProviderKind 变更,fixture 更新后这里的 deepEqual 会红, + 提示同步本表。评审 P2 裁定:不读 fixture kind(那会使对照变成同义反复)。 */ + const SPECIAL_KINDS = { rule: "rule", zhipu: "zhipu", anthropic: "anthropic" }; + const norm = (id, r) => ({ + id, + label: r.label, + kind: SPECIAL_KINDS[id] ?? "openai-compat", + model: r.model, + base: r.base, + local: !!r.local, + caps: { + contextWindow: r.caps && r.caps.contextWindow != null ? r.caps.contextWindow : null, + structuredOutput: !!(r.caps && r.caps.structuredOutput), + tools: !!(r.caps && r.caps.tools), + imageInput: !!(r.caps && r.caps.imageInput), + promptCache: !!(r.caps && r.caps.promptCache), + }, + price: r.price + ? { currency: r.price.currency, promptMicroPerM: r.price.promptMicroPerM, + completionMicroPerM: r.price.completionMicroPerM, cachedMicroPerM: r.price.cachedMicroPerM } + : null, + }); + for (const want of rows) { + assert.ok(PROVIDER_PRESETS[want.id], `kernel missing preset: ${want.id}`); + assert.deepEqual(norm(want.id, PROVIDER_PRESETS[want.id]), want, `preset row mismatch: ${want.id}`); + } +}); + /* ---- parseIntent:与 Rust L9 parse 同词汇表(镜像断言) ---- */ test("parse 镜像:spawn/clear/gravity/teleport/count/speed", () => { assert.deepEqual(parseIntent("spawn 40 particles"), { kind: "spawn", n: 40 }); @@ -249,10 +305,12 @@ test("导演系统:注入 ruleBeats 生效", async () => { assert.equal(beat, "甲事件"); }); -/* ---- 预设表:15 行(rule + xlite 自家网关 + 云端 9 + 本地开源 4) ---- */ -test("预设表 15 行(rule 领头,xlite 第二,云端 9 + 本地开源 4)", () => { +/* ---- 预设表:行数与 Rust PROVIDER_PRESETS 对齐(行数单一来源=presets.json, + 评审 P2:硬编码行数会让加行的演进成本多一处) ---- */ +test("预设表行数与构成(rule 领头,xlite 第二,云端 + 本地开源)", () => { const ids = Object.keys(PROVIDER_PRESETS); - assert.equal(ids.length, 15); + const { rows } = JSON.parse(readFileSync(PRESET_FIXTURE, "utf8")); + assert.equal(ids.length, rows.length); assert.equal(ids[0], "rule"); assert.equal(ids[1], "xlite"); for (const id of ["qwen", "stepfun", "ollama", "vllm", "llamacpp", "lmstudio"]) assert.ok(ids.includes(id), id);