From 02b00e03504280d54aa80ed96ac1504e1cb122af Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:36:31 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf(subsys):=20rigid=20=E5=86=99=E5=9B=9E?= =?UTF-8?q?=20O(n=C2=B2)=E2=86=92O(n)=20=E5=8D=95=E9=81=8D=E5=88=B7?= =?UTF-8?q?=E5=86=99=EF=BC=88#100=EF=BC=8C=E8=AF=84=E5=AE=A1=20E3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 步骤 4 原每实体一次 for_each2_mut 全列扫(n 体 = n 次全列遍历,1k 体即卡帧) → writeback 暂存表(clear() 复用分配,EntKey→钳制后 xyz)+ 单次列遍历查表刷写 - 每实体写回只依赖自身 body 状态,批量刷写保持写序语义 - 新增 thousand_bodies_all_receive_their_writeback:1k 体线性运动无接触精确 断言(dx=vx·dt,漏写即红);two_runs_are_bit_identical 红线不动 - 裁定:全岛休眠跳步不做——rapier islands 自带 sleep,跳步引入语义风险 大于收益;时间断言 bench 归 #103 bench 四件套(避免 CI 时间 flaky) --- crates/oriera-subsys/src/rigid.rs | 57 +++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/oriera-subsys/src/rigid.rs b/crates/oriera-subsys/src/rigid.rs index c3b70ca..13469b4 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,36 @@ 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 (spacing > 2·radius), 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 = 5.0 + (i % 25) as f32 * 17.0; + let y0 = 5.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(); From 8a07bc1e45d2ef60ad967d22842d8715bc16ac7f Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:36:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(contracts):=20=E5=8F=8C=E7=AB=AF?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=8D=95=E4=B8=80=E6=9D=A5=E6=BA=90=20golden?= =?UTF-8?q?=20fixture=EF=BC=88#98=EF=BC=8C=E8=AF=84=E5=AE=A1=E5=9B=9B?= =?UTF-8?q?=E6=96=B9=E5=85=B1=E8=AF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts/golden/intents.json:parse 词汇 28 用例(中英+否定+落空), Rust parse() 与 kernel parseIntent() 双端共读镜像断言 - contracts/golden/presets.json:预设 14 行全字段(caps 五字段恒显式/ price 四字段或 null),Rust preset_json 规范化对照 + kernel norm 对照 - 测试挂 dev-dependencies serde_json(不受 feature 门控,任何 CI 腿都跑) - 改任一侧解析/预设而不更新 fixture → 双端 CI 同红——漂移从此有闸门 - 现状两侧表一致性同时得到实证(此前仅注释纪律维持) --- contracts/golden/intents.json | 33 ++++++ contracts/golden/presets.json | 47 ++++++++ crates/oriera-agent/Cargo.toml | 2 + crates/oriera-agent/src/lib.rs | 61 ++++++++++ crates/oriera-infer/Cargo.toml | 4 + crates/oriera-infer/src/provider.rs | 125 +++++++++++++++++++++ examples/11-ai-cafe/engine/kernel.test.mjs | 51 +++++++++ 7 files changed, 323 insertions(+) create mode 100644 contracts/golden/intents.json create mode 100644 contracts/golden/presets.json diff --git a/contracts/golden/intents.json b/contracts/golden/intents.json new file mode 100644 index 0000000..c3f4d1f --- /dev/null +++ b/contracts/golden/intents.json @@ -0,0 +1,33 @@ +{ + "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": "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..bd01827 --- /dev/null +++ b/contracts/golden/presets.json @@ -0,0 +1,47 @@ +{ + "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": "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..0612932 100644 --- a/crates/oriera-agent/src/lib.rs +++ b/crates/oriera-agent/src/lib.rs @@ -211,4 +211,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/provider.rs b/crates/oriera-infer/src/provider.rs index 8732fb5..cca3625 100644 --- a/crates/oriera-infer/src/provider.rs +++ b/crates/oriera-infer/src/provider.rs @@ -1451,4 +1451,129 @@ 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(); + assert!(report.iter().any(|(id, _, _)| *id == "always-down")); + } } diff --git a/examples/11-ai-cafe/engine/kernel.test.mjs b/examples/11-ai-cafe/engine/kernel.test.mjs index 1680e8c..8ef0619 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,56 @@ 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"); + const norm = (id, r) => ({ + id, + label: r.label, + kind: id === "zhipu" ? "zhipu" : id === "anthropic" ? "anthropic" : id === "rule" ? "rule" : "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 }); From bbb5c8f245515777425e36d481d32f96aad23cde Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:36:32 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(stability):=20B8=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E9=97=A8=E7=A6=81=E8=8A=82=EF=BC=88#99?= =?UTF-8?q?=EF=BC=8C=E8=AF=84=E5=AE=A1=20AI=20=E5=8E=9F=E7=94=9F=20P0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 门禁入口 b8_gate_smoke:rule 参照后端四腿聚合冒烟(确定性/usage 计量/ 流式拼接诚实/降级链韧性),离线零网络进 CI workspace 腿 - 深度四腿不重复——STABILITY.md §B8 显式指认既有常驻测试清单 - 裁定:行为腿 #92-#95 已高度覆盖,B8 增量=显式入口+登记单一来源, 不新开 CI 独立腿(native-http 腿已常驻)避免时长膨胀 --- docs/STABILITY.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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 时长膨胀。 + ## 三、已知不稳定边界(诚实清单) 以下能力**尚未达到上述承诺**,使用前须知: From b28f43a4b0485935fffbfa365c90cb1e37c7cca2 Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:59:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E8=AF=84=E5=AE=A1=E5=8F=8C=20P1=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20rebase=20=E5=90=8E=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20xlite=20=E5=8F=8C=E7=AB=AF=E6=BC=82?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审发现(两位独立评审 PASS-with-findings,红线零违反): - [P1] 全角数字双端漂移实锤(JS v2.1 已归一、Rust 无)→ Rust parse 补 全角归一(对镜 kernel),fixture 新增放5个/生成10个钉住 - [P1] 覆盖缺口必补:清屏(clear 第三同义词)、清空,但别关灯(否定后置 不抑制,比较方向翻转 fixture 必红) - [P2] b8_gate leg4 断言强化:health_report 必须记到失败计数(name-only 检查防不住 record() no-op 回归) - [P2] 1k 测试网格原点 5→9:原 origin-5 对墙嵌入 3 单位(r=8),断言成立 依赖 rapier 软接触;现体-体/体-墙全程无接触,前提为真 - [P2] kernel.test.mjs 行数断言改读 presets.json rows.length(演进少一处) - [P2] JS kind 推导裁定保留(读 fixture 会成同义反复),注释化说明 rebase 到 origin/main(上游 4 提交)后 golden 门禁当场抓到新漂移: - kernel 侧先行加了 xlite 行(15)而 Rust 表仍 14 → Rust 补 xlite 行 + presets.json 第 15 行,双端重新对齐 - price_for_label kind:model 撞行(xlite/openai 同为 gpt-4o-mini)→ 改为 首个有登记价的行胜出(网关未登记价按源厂牌价记账,绝不猜价) - 顺手 cargo fmt:上游 2f93226 带入的 http.rs 三处格式残留(CI 首红主因) --- contracts/golden/intents.json | 6 ++++- contracts/golden/presets.json | 3 +++ crates/oriera-agent/src/lib.rs | 14 ++++++++++- crates/oriera-infer/src/http.rs | 16 +++++++++--- crates/oriera-infer/src/lib.rs | 7 ++++-- crates/oriera-infer/src/provider.rs | 29 ++++++++++++++++++++-- crates/oriera-subsys/src/rigid.rs | 11 ++++---- examples/11-ai-cafe/engine/kernel.test.mjs | 15 ++++++++--- 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/contracts/golden/intents.json b/contracts/golden/intents.json index c3f4d1f..7d69355 100644 --- a/contracts/golden/intents.json +++ b/contracts/golden/intents.json @@ -1,5 +1,5 @@ { - "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。", + "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 } }, @@ -7,6 +7,10 @@ { "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 } }, diff --git a/contracts/golden/presets.json b/contracts/golden/presets.json index bd01827..3a5c829 100644 --- a/contracts/golden/presets.json +++ b/contracts/golden/presets.json @@ -4,6 +4,9 @@ { "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 }, diff --git a/crates/oriera-agent/src/lib.rs b/crates/oriera-agent/src/lib.rs index 0612932..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()); } 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 cca3625..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] @@ -1574,6 +1591,14 @@ mod b8_gate { let out = fb.complete(&msgs()).expect("leg4 fallback"); assert!(!out.is_empty(), "fallback must still answer"); let report = fb.health_report(); - assert!(report.iter().any(|(id, _, _)| *id == "always-down")); + // 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 13469b4..a4dcc58 100644 --- a/crates/oriera-subsys/src/rigid.rs +++ b/crates/oriera-subsys/src/rigid.rs @@ -403,17 +403,18 @@ mod tests { } /// #100 regression: the batched single-pass write-back must reach - /// EVERY body. Linear motion, no contacts (spacing > 2·radius), so - /// each row must advance exactly `vx·dt` — a missed write leaves - /// that body at dx = 0. + /// 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 = 5.0 + (i % 25) as f32 * 17.0; - let y0 = 5.0 + (i / 25) as f32 * 17.0; + 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)); diff --git a/examples/11-ai-cafe/engine/kernel.test.mjs b/examples/11-ai-cafe/engine/kernel.test.mjs index 8ef0619..d2921a8 100644 --- a/examples/11-ai-cafe/engine/kernel.test.mjs +++ b/examples/11-ai-cafe/engine/kernel.test.mjs @@ -36,10 +36,15 @@ test("parse 镜像:golden fixture 共读(intents.json 单一来源)", () = 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: id === "zhipu" ? "zhipu" : id === "anthropic" ? "anthropic" : id === "rule" ? "rule" : "openai-compat", + kind: SPECIAL_KINDS[id] ?? "openai-compat", model: r.model, base: r.base, local: !!r.local, @@ -300,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);