From 0572eb897e7e02fe2d71c66f5433bcae4f9271aa Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:27:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(core,infer,agent,genai):=20E=20?= =?UTF-8?q?=E6=89=B9=E4=BA=8C=E8=BD=AE=E2=80=94=E2=80=94=E7=A1=AE=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=E4=B8=89=E8=A1=A5=20+=20=E6=80=A7=E8=83=BD=E4=B8=89?= =?UTF-8?q?=E4=BF=AE=20+=20=E5=B7=A5=E7=A8=8B=E5=8D=AB=E7=94=9F=E5=8C=85?= =?UTF-8?q?=EF=BC=88#102/#103/#106=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #102 确定性(评审 E5): - ModelProvider::current_seed() 默认 None;HttpProvider 上报 ProviderOptions.seed - cognition 台账指纹透传真实 seed——Pinned 档同 prompt 不同 seed 可区分(对账语义成立) - InferSystem::with_seed(u64) + seed() getter:run 级种子可注入(#84 前置), 同 seed 双跑 state_hash 逐位一致、异 seed 发散(state_hash 断言) - 裁定:指纹域不加 budget——指纹语义是「该 prompt 是否出现过」,budget 改变 只在其触发裁剪时自然改变 prompt 与指纹;run-ledger 落盘格式需 RFC 化留 issue #103 性能(评审 E12/E4): - L3 事件队列 Vec+remove(0) O(n) → VecDeque push_back/pop_front O(1) - BudgetAllocator publish 每帧 name.clone() 清零:grant 表对齐后原地刷数字, 稳态零分配;挤压步 shares Vec 改两遍循环 - World::state_hash 全列物化 → fold_column 流式 FNV(万体哈希零中间分配), 混合顺序逐位不变 - B7 熔断→EWMA 接线 API:Scheduler::observe_extra_cost(负值钳零——熔断 open 是 fail-fast,不建模会出现「失败反而赢得预算」的反向激励)+ FallbackProvider::regulator_hint_ms(最差非闭合链的建模虚拟成本); 壳层接法两行,文档与测试钉死「持续虚拟过载必被挤压」 #106 工程卫生(评审架构/QA/AI 共识): - 去 deepseek-recipe-core 依赖:ReasoningEffort wire 值本地 as_str() 逐字节 等价且 wire 快照测试钉死,proc-macro 供应链面清零(两轮评审共识,本次拍板) - L8 镜像错误分类:GenAiErrorClass(同 id 词表/同 retryable 政策,不依赖 oriera-infer——ADR 0003 解耦保持),Provider 变体关键词嗅探传输事实 - studio_e2e 三赛道离线验收进 CI 常驻腿(panic 即红) 全量:clippy --all-targets 0 告警 / fmt 净 / workspace 238 / genai 54 / agent-json 60 / infer native-http 97 / rigid 15 / kernel 35 / e2e 三赛道 PASS --- .github/workflows/ci.yml | 2 + Cargo.toml | 9 +-- crates/oriera-agent/src/cognition.rs | 48 +++++++++++- crates/oriera-core/src/allocator.rs | 48 ++++++------ crates/oriera-core/src/lib.rs | 67 ++++++++++++++++ crates/oriera-ecs/src/lib.rs | 65 ++++++++------- crates/oriera-genai/src/lib.rs | 113 +++++++++++++++++++++++++++ crates/oriera-infer/Cargo.toml | 8 +- crates/oriera-infer/src/health.rs | 21 +++++ crates/oriera-infer/src/http.rs | 51 +++++++----- crates/oriera-infer/src/lib.rs | 48 ++++++++++++ crates/oriera-infer/src/provider.rs | 10 +++ 12 files changed, 405 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04184d2..534be7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,8 @@ jobs: run: cargo test -p oriera-infer --features native-http - name: Test (subsys rigid feature) run: cargo test -p oriera-subsys --features rigid + - name: Offline acceptance example (studio_e2e — panic is red, #106) + run: cargo run -q -p oriera-agent --example studio_e2e - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: Clippy (genai providers feature) diff --git a/Cargo.toml b/Cargo.toml index eb51b68..4762e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,12 +52,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } ureq = { version = "2.10", default-features = false, features = ["tls"] } # A1 (PLAN-deepseek-upgrade): DeepSeek 官方协议类型层(conversation/ # ReasoningEffort 等),MIT。只取 core——主 crate 拖 tokenizers/jsonschema -# 且无 feature 可关,core 仅依赖 macros+serde+serde_json,近乎零增量。 -# caret 0.2 = 锁 0.2.x,上游 0.3 升级走独立 commit 留观察窗。 -# 已知边界(2026-10-21 安全评审):当前仅用 ReasoningEffort 的官方 -# 序列化形状(本地 as_str() 等价)——去依赖化是候选项,待创始人拍板 -# (A1 拍板的是「直接依赖官方协议层」,删除需重新拍板)。 -deepseek-recipe-core = "0.2" +# deepseek-recipe-core 已移除(#106,2026-10-23 拍板去依赖化):仅用 +# ReasoningEffort 的 wire 形状,本地 as_str() 等价且 wire 快照测试钉死; +# reasoning_effort 的线上格式契约 = every_effort_variant_maps_to_the_recipe_snake_case。 # L6 rigid backend (#80): pure Rust, Apache-2.0, 2026 roadmap targets # robotics accuracy. Feature-gated per crate (`rigid`). rapier2d = "0.36" diff --git a/crates/oriera-agent/src/cognition.rs b/crates/oriera-agent/src/cognition.rs index 4fa21b0..b93035b 100644 --- a/crates/oriera-agent/src/cognition.rs +++ b/crates/oriera-agent/src/cognition.rs @@ -512,7 +512,7 @@ fn record_fingerprint( ) { let prompt = oriera_infer::prompt_fingerprint( provider.id(), - None, + provider.current_seed(), &[ oriera_infer::ChatMessage::system(sys.to_string()), oriera_infer::ChatMessage::user(user.to_string()), @@ -784,6 +784,52 @@ mod tests { } } + /// #102: a provider that reports a run-level seed — the ledger must + /// carry it into the prompt fingerprint (Pinned 对账的前提:同文案 + /// 不同 seed 的两次调用在台账里可区分)。 + struct Seeded { + inner: RuleProvider, + seed: Option, + } + impl ModelProvider for Seeded { + fn id(&self) -> &str { + "seeded" + } + fn complete( + &self, + messages: &[oriera_infer::ChatMessage], + ) -> Result { + self.inner.complete(messages) + } + fn current_seed(&self) -> Option { + self.seed + } + } + + fn last_ledger_prompt(seed: Option) -> u64 { + let mut w = cafe_world(); + let p = std::sync::Arc::new(Seeded { + inner: RuleProvider::new(), + seed, + }); + CognitionSystem::new(p.clone(), 1).run(&ctx(), &mut w); + let ledger = w.resource_or_default::(); + ledger + .entries + .last() + .map(|e| e.prompt) + .expect("one cognition call must be recorded") + } + + #[test] + fn ledger_fingerprint_carries_provider_seed() { + let none = last_ledger_prompt(None); + let s7 = last_ledger_prompt(Some(7)); + let s9 = last_ledger_prompt(Some(9)); + assert_ne!(none, s7, "seeded vs seedless must differ"); + assert_ne!(s7, s9, "different seeds must differ"); + } + fn capture_run(scene: Option<&str>) -> String { let mut w = cafe_world(); let cap = std::sync::Arc::new(CaptureProvider { diff --git a/crates/oriera-core/src/allocator.rs b/crates/oriera-core/src/allocator.rs index 8e4295e..52dc100 100644 --- a/crates/oriera-core/src/allocator.rs +++ b/crates/oriera-core/src/allocator.rs @@ -171,10 +171,23 @@ impl BudgetAllocator { /// registration order; `out` is reused frame to frame so /// steady-state ticks allocate nothing. pub fn allocate(&mut self, budget_ms: f32, out: &mut Vec<(String, ComputeGrant)>) { - out.clear(); + // Steady-state zero allocation (#103, review E12): keep the + // caller's table sized to the registry and refresh numbers in + // place -- the per-frame name.clone() in publish is gone. + // Growth (and its one clone per row) happens only when a new + // system registers. + out.truncate(self.names.len()); + for name in self.names[out.len()..].iter() { + out.push((name.clone(), ComputeGrant::default())); + } if !self.config.enabled || self.states.is_empty() { + // The "unmanaged = empty table" contract stays: an off + // allocator publishes nothing (AllocatorConfig::off keeps + // v0.3 bit-identical passthrough). + out.clear(); return; } + debug_assert_eq!(out.len(), self.names.len()); let total: f32 = self.states.iter().map(|s| s.ema_ms.max(0.0)).sum(); let load = if budget_ms > 0.0 { total / budget_ms @@ -201,12 +214,7 @@ impl BudgetAllocator { /// proportional-fairness target `share / cost` (= 1/load for /// spenders above their floor). Idempotent — re-deriving the same /// target while the condition persists is stable, not compounding. - fn over_streak_tick( - &mut self, - out: &mut Vec<(String, ComputeGrant)>, - budget_ms: f32, - total: f32, - ) { + fn over_streak_tick(&mut self, out: &mut [(String, ComputeGrant)], budget_ms: f32, total: f32) { let mut over = 0; for s in &mut self.states { s.over_streak += 1; @@ -216,12 +224,10 @@ impl BudgetAllocator { if over >= self.config.squeeze_after { let min_share = self.config.min_share; let floor = self.config.floor; - let shares: Vec = self - .states - .iter() - .map(|s| Self::share_of(s.ema_ms, budget_ms, total, min_share)) - .collect(); - for (s, share) in self.states.iter_mut().zip(shares) { + // Two passes, no intermediate Vec (#103): share_of is pure, + // recomputing it beats allocating on the rare squeeze step. + for s in &mut self.states { + let share = Self::share_of(s.ema_ms, budget_ms, total, min_share); let target = if s.ema_ms > f32::EPSILON { share / s.ema_ms } else { @@ -237,7 +243,7 @@ impl BudgetAllocator { /// Sustained real headroom: everyone recovers one small step. fn under_streak_tick( &mut self, - out: &mut Vec<(String, ComputeGrant)>, + out: &mut [(String, ComputeGrant)], budget_ms: f32, total: f32, ) { @@ -268,15 +274,11 @@ impl BudgetAllocator { (budget_ms * weight).max(budget_ms * min_share) } - fn publish(&self, budget_ms: f32, total: f32, out: &mut Vec<(String, ComputeGrant)>) { - for (name, state) in self.names.iter().zip(self.states.iter()) { - out.push(( - name.clone(), - ComputeGrant { - share_ms: Self::share_of(state.ema_ms, budget_ms, total, self.config.min_share), - throttle: state.throttle.clamp(self.config.floor, 1.0), - }, - )); + fn publish(&self, budget_ms: f32, total: f32, out: &mut [(String, ComputeGrant)]) { + debug_assert_eq!(out.len(), self.names.len()); + for (slot, state) in out.iter_mut().zip(self.states.iter()) { + slot.1.share_ms = Self::share_of(state.ema_ms, budget_ms, total, self.config.min_share); + slot.1.throttle = state.throttle.clamp(self.config.floor, 1.0); } } diff --git a/crates/oriera-core/src/lib.rs b/crates/oriera-core/src/lib.rs index 36fe041..61a45b5 100644 --- a/crates/oriera-core/src/lib.rs +++ b/crates/oriera-core/src/lib.rs @@ -61,6 +61,10 @@ pub struct Scheduler { /// AI-native apps that is usually cognition or inference. Names /// are captured once at `add_system`; ticks only rewrite the ms. system_ms: Vec<(String, f32)>, + /// Externally supplied virtual costs (#103 E4), folded into the + /// next tick's allocator observation — tick measures real ms by + /// assignment, so extras must merge in after measurement. + extra_ms: Vec<(String, f32)>, /// Fixed simulation step (issue #66). `Some(dt)` makes every /// `FrameCtx::dt` exactly `dt` regardless of wall-clock pacing — /// the determinism dial for replay/evaluation workloads. `None` @@ -93,6 +97,7 @@ impl Scheduler { budget_warnings: 0, budget_ns: budget_ns.max(1), system_ms: Vec::new(), + extra_ms: Vec::new(), fixed_dt: None, allocator: BudgetAllocator::default(), grant_table: Vec::new(), @@ -196,6 +201,15 @@ impl Scheduler { timing.1 = Timestamp::now().elapsed(t0) * 1000.0; } + // Merge externally supplied virtual costs (#103 E4) AFTER the + // real measurements so they survive to this frame's observation. + for (n, e) in &self.extra_ms { + if let Some((_, ms)) = self.system_ms.iter_mut().find(|(sn, _)| sn == n) { + *ms += e; + } + } + self.extra_ms.clear(); + self.allocator.observe(&self.system_ms); let frame_ms = Timestamp::now().elapsed(start) * 1000.0; @@ -238,6 +252,26 @@ impl Scheduler { } } + /// Fold an *externally derived* virtual cost into one system's + /// observed ms before the next allocator observation (#103, review + /// E4: wire the B7 breaker state into the EWMA — a circuit that is + /// open is fail-fast, so without compensation a failing AI channel + /// would *win* budget, the perverse incentive). The shell computes + /// the value, e.g. via + /// [`FallbackProvider::regulator_hint_ms`](oriera_infer)'s + /// modelled retry/degradation latency. Negative inputs are clamped + /// to zero: this API can only *add* pressure, never fake headroom. + pub fn observe_extra_cost(&mut self, system_name: &str, extra_ms: f32) { + let extra = extra_ms.max(0.0); + if extra == 0.0 { + return; + } + match self.extra_ms.iter_mut().find(|(n, _)| n == system_name) { + Some((_, v)) => *v += extra, + None => self.extra_ms.push((system_name.to_string(), extra)), + } + } + /// Mean frame time over the last 60 frames, in ms. pub fn avg_frame_ms(&self) -> f32 { if self.frame_times.is_empty() { @@ -297,6 +331,39 @@ mod tests { assert!((desktop.budget_ms() - 16.6666).abs() < 0.01); } + #[test] + fn extra_cost_pressures_the_ewma_and_never_fakes_headroom() { + // #103 E4: a failing (breaker-open) AI channel must LOSE budget + // headroom, not win it — negative input is clamped, unknown + // system names are a no-op. + let mut sched: Scheduler = Scheduler::with_budget_ns(16_000_000); + sched.add_system(Box::new(IncSystem)); + let mut world = 0u32; + sched.tick(&mut world); + sched.observe_extra_cost(IncSystem.name(), 100.0); + sched.observe_extra_cost(IncSystem.name(), -50.0); // clamped away + sched.observe_extra_cost("no-such-system", 99.0); // no-op + // The fold lands on the NEXT tick's allocator observation + // (real measurements are taken by assignment each tick, extras + // merge in after them): +100ms virtual on a 16ms budget must + // push the observed load past 1.0 even through EWMA damping. + sched.tick(&mut world); + let load1 = sched.allocator().load(sched.budget_ms()); + assert!(load1 > 1.0, "folded cost must register as load: {load1}"); + // Sustained virtual overload must squeeze the line below full + // speed (EWMA converges, then the overrun streak trips it) — + // an open breaker LOSES headroom, never wins it. + for _ in 0..40 { + sched.observe_extra_cost(IncSystem.name(), 100.0); + sched.tick(&mut world); + } + let throttled = sched.allocator().throttle_of(IncSystem.name()); + assert!( + throttled < 1.0, + "an over-budget line must be squeezed: throttle={throttled}" + ); + } + #[test] fn tick_records_per_system_timings_in_run_order() { let mut sched: Scheduler = Scheduler::new(); diff --git a/crates/oriera-ecs/src/lib.rs b/crates/oriera-ecs/src/lib.rs index 60e1712..61ed2fa 100644 --- a/crates/oriera-ecs/src/lib.rs +++ b/crates/oriera-ecs/src/lib.rs @@ -39,7 +39,7 @@ //! ``` use std::any::{Any, TypeId}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; // --------------------------------------------------------------------------- // Entity @@ -311,13 +311,13 @@ impl World { let queue = self .events .entry(TypeId::of::()) - .or_insert_with(|| Box::new(Vec::::new()) as Box); + .or_insert_with(|| Box::new(VecDeque::::new()) as Box); let v = queue - .downcast_mut::>() + .downcast_mut::>() .expect("event queue keyed by TypeId of T"); - v.push(event); + v.push_back(event); if v.len() > Self::MAX_PENDING_EVENTS { - v.remove(0); + v.pop_front(); tracing::warn!( ty = std::any::type_name::(), cap = Self::MAX_PENDING_EVENTS, @@ -331,11 +331,11 @@ impl World { /// only what was written after the first drain. pub fn take_events(&mut self) -> Vec { match self.events.get_mut(&TypeId::of::()) { - Some(queue) => std::mem::take( - queue - .downcast_mut::>() - .expect("event queue keyed by TypeId of T"), - ), + Some(queue) => queue + .downcast_mut::>() + .expect("event queue keyed by TypeId of T") + .drain(..) + .collect(), None => Vec::new(), } } @@ -503,26 +503,18 @@ impl World { // constant; changing it changes every fingerprint ever taken. let mut h: u64 = 0x0000_6f72_6965_7261; mix(&mut h, self.live_count as u64); - for (ty, bits_of) in [ + for (ty, fold) in [ ( TypeId::of::(), - (|any: &dyn Any| -> Vec<(usize, u64, u64)> { - column_bits::(any, |p| (p.x.to_bits() as u64, p.y.to_bits() as u64)) - }) as fn(&dyn Any) -> Vec<(usize, u64, u64)>, + fold_position_column as fn(&dyn Any, &mut u64), ), ( TypeId::of::(), - |any: &dyn Any| -> Vec<(usize, u64, u64)> { - column_bits::(any, |v| (v.vx.to_bits() as u64, v.vy.to_bits() as u64)) - }, + fold_velocity_column as fn(&dyn Any, &mut u64), ), ] { if let Some(col) = self.columns.get(&ty) { - for (idx, a, b) in bits_of(col.as_any()) { - mix(&mut h, idx as u64); - mix(&mut h, a); - mix(&mut h, b); - } + fold(col.as_any(), &mut h); } } h @@ -537,20 +529,27 @@ fn mix(h: &mut u64, word: u64) { /// Read one typed column as (entity index, value-word pairs) in index /// order, skipping empty slots. Shared by [`World::state_hash`]. -fn column_bits( - any: &dyn Any, - words: impl Fn(&T) -> (u64, u64), -) -> Vec<(usize, u64, u64)> { - match any.downcast_ref::>>() { - Some(col) => col - .iter() - .enumerate() - .filter_map(|(idx, slot)| slot.as_ref().map(|v| (idx, words(v).0, words(v).1))) - .collect(), - None => Vec::new(), +fn fold_column(any: &dyn Any, h: &mut u64, words: impl Fn(&T) -> (u64, u64)) { + if let Some(col) = any.downcast_ref::>>() { + for (idx, slot) in col.iter().enumerate() { + if let Some(v) = slot { + let (a, b) = words(v); + mix(h, idx as u64); + mix(h, a); + mix(h, b); + } + } } } +fn fold_position_column(any: &dyn Any, h: &mut u64) { + fold_column::(any, h, |p| (p.x.to_bits() as u64, p.y.to_bits() as u64)); +} + +fn fold_velocity_column(any: &dyn Any, h: &mut u64) { + fold_column::(any, h, |v| (v.vx.to_bits() as u64, v.vy.to_bits() as u64)); +} + // --------------------------------------------------------------------------- // Engine-standard components (L3 object vocabulary) // --------------------------------------------------------------------------- diff --git a/crates/oriera-genai/src/lib.rs b/crates/oriera-genai/src/lib.rs index 1841055..458ef77 100644 --- a/crates/oriera-genai/src/lib.rs +++ b/crates/oriera-genai/src/lib.rs @@ -248,6 +248,82 @@ pub enum GenAiError { Validation(String), } +/// Mirrors L7 `oriera_infer::ErrorClass` **semantics** (same stable ids) +/// without depending on oriera-infer — ADR 0003 keeps generation and +/// inference decoupled, so the vocabulary is copied, not imported +/// (#106, review E8: CogView/Tripo/Seedance hit 429/402 too, and slot +/// degradation needs the same retry/fall-through policy words). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GenAiErrorClass { + RateLimited, + Auth, + Quota, + Server, + Timeout, + Provider, +} + +impl GenAiErrorClass { + /// Stable id for metrics labels — identical strings to L7's ids so + /// a shared dashboard can chart both layers under one axis. + pub fn id(self) -> &'static str { + match self { + GenAiErrorClass::RateLimited => "rate_limited", + GenAiErrorClass::Auth => "auth", + GenAiErrorClass::Quota => "quota", + GenAiErrorClass::Server => "server", + GenAiErrorClass::Timeout => "timeout", + GenAiErrorClass::Provider => "provider", + } + } + + /// Same policy as L7: transport-transient classes are worth backing + /// off (or falling through the chain); capability/config failures + /// are not — retrying cannot change the outcome. + pub fn retryable(self) -> bool { + matches!( + self, + GenAiErrorClass::RateLimited | GenAiErrorClass::Server | GenAiErrorClass::Timeout + ) + } +} + +impl GenAiError { + /// Fold into the public class vocabulary. `Unsupported` is a + /// capability fact (never retryable); `Validation` is a produced- + /// asset fact (the request reached and answered — provider bucket); + /// `Provider` messages are keyword-sniffed for the transport facts + /// adapters embed (429/401/402/5xx/timeout). + pub fn class(&self) -> GenAiErrorClass { + match self { + GenAiError::Unsupported(_) => GenAiErrorClass::Provider, + GenAiError::Validation(_) => GenAiErrorClass::Provider, + GenAiError::Provider(msg) => { + let m = msg.to_ascii_lowercase(); + if m.contains("429") || m.contains("rate limit") || m.contains("too many requests") + { + GenAiErrorClass::RateLimited + } else if m.contains("401") || m.contains("403") { + GenAiErrorClass::Auth + } else if m.contains("402") { + GenAiErrorClass::Quota + } else if m.contains("timeout") || m.contains("timed out") { + GenAiErrorClass::Timeout + } else if m.contains("500") || m.contains("502") || m.contains("503") { + GenAiErrorClass::Server + } else { + GenAiErrorClass::Provider + } + } + } + } + + /// Retry/fall-through policy (delegates to the class). + pub fn retryable(&self) -> bool { + self.class().retryable() + } +} + impl fmt::Display for GenAiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -489,6 +565,43 @@ pub(crate) fn tiny_png() -> Vec { #[cfg(test)] mod tests { + #[test] + fn genai_error_classes_mirror_l7_semantics() { + use super::*; + // 能力缺失:不可重试 + assert_eq!( + GenAiError::Unsupported("no mesh kind".into()).class(), + GenAiErrorClass::Provider + ); + assert!(!GenAiError::Unsupported("x".into()).retryable()); + // 传输事实关键词嗅探 + assert_eq!( + GenAiError::Provider("HTTP 429: slow down".into()).class(), + GenAiErrorClass::RateLimited + ); + assert!(GenAiError::Provider("HTTP 429".into()).retryable()); + assert_eq!( + GenAiError::Provider("HTTP 401: bad key".into()).class(), + GenAiErrorClass::Auth + ); + assert_eq!( + GenAiError::Provider("HTTP 402: out of balance".into()).class(), + GenAiErrorClass::Quota + ); + assert_eq!( + GenAiError::Provider("request timed out".into()).class(), + GenAiErrorClass::Timeout + ); + assert!(GenAiError::Provider("gateway 502".into()).retryable()); + assert_eq!( + GenAiError::Provider("weird garbage".into()).class(), + GenAiErrorClass::Provider + ); + // 稳定 id 与 L7 同词表 + assert_eq!(GenAiErrorClass::RateLimited.id(), "rate_limited"); + assert_eq!(GenAiErrorClass::Auth.id(), "auth"); + } + use super::*; fn asset(format: AssetFormat, payload: Vec) -> GeneratedAsset { diff --git a/crates/oriera-infer/Cargo.toml b/crates/oriera-infer/Cargo.toml index 7542cf3..181ca61 100644 --- a/crates/oriera-infer/Cargo.toml +++ b/crates/oriera-infer/Cargo.toml @@ -19,15 +19,15 @@ tracing.workspace = true # #68 native HTTP adapter (feature `native-http`, design discussions/78): # ureq blocking client + serde_json for request/response shapes. Both # optional — wasm budget and default builds stay exactly as they were. -# A1 (PLAN-deepseek-upgrade) adds deepseek-recipe-core: the official -# protocol type layer (ReasoningEffort wire shaping), same feature gate. +# recipe-core 依赖已移除(#106 两轮评审共识:仅用 ReasoningEffort 的 +# wire 形状,本地 as_str() 逐字节等价且有 wire 测试钉死,proc-macro +# 供应链面不值得;A1 的「官方协议层」由测试锁定的 wire 快照承接)。 ureq = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } -deepseek-recipe-core = { workspace = true, optional = true } [features] default = [] -native-http = ["dep:ureq", "dep:serde_json", "dep:deepseek-recipe-core"] +native-http = ["dep:ureq", "dep:serde_json"] [dev-dependencies] # golden fixture test (#98) reads contracts/golden/presets.json on every test leg diff --git a/crates/oriera-infer/src/health.rs b/crates/oriera-infer/src/health.rs index 6f42d35..4c8e426 100644 --- a/crates/oriera-infer/src/health.rs +++ b/crates/oriera-infer/src/health.rs @@ -238,6 +238,21 @@ impl FallbackProvider { .collect() } + /// Modelled virtual cost (#103, review E4): shells feed this into + /// `Scheduler::observe_extra_cost("infer", ..)` so an open/half-open + /// breaker *loses* allocation headroom instead of winning it (the + /// breaker is fail-fast — unmodelled, failure would look cheap). + /// Cost = base × consecutive failures of the worst non-closed link, + /// standing in for the retry + degradation latency the shell would + /// really pay. Fully-closed chains report 0. + pub fn regulator_hint_ms(&self, base_ms: f32) -> f32 { + self.health_report() + .iter() + .filter(|(_, state, _)| *state != BreakerState::Closed) + .map(|(_, _, fails)| base_ms * (*fails as f32).max(1.0)) + .fold(0.0_f32, f32::max) + } + fn record(&self, idx: usize, error: Option<&InferError>) { let mut h = self.healths[idx].lock().expect("health mutex"); match error { @@ -510,6 +525,12 @@ mod tests { let report = fb.health_report(); assert_eq!(report[0].1, BreakerState::Open); assert_eq!(report[1].1, BreakerState::Closed); + // #103 E4: open × accumulated failures → modelled virtual cost. + let hint = fb.regulator_hint_ms(20.0); + assert!( + hint >= 40.0, + "open breaker must cost at least 2×base: {hint}" + ); } #[test] diff --git a/crates/oriera-infer/src/http.rs b/crates/oriera-infer/src/http.rs index 4154f30..4099948 100644 --- a/crates/oriera-infer/src/http.rs +++ b/crates/oriera-infer/src/http.rs @@ -50,20 +50,6 @@ compile_error!( kernel.js HttpChatProvider mirror (examples/11-ai-cafe/engine/kernel.js)" ); -/// Our dial maps 1:1 onto the recipe's canonical wire enum (A1) — the -/// copy exists only so the crate stays dependency-free below the -/// `native-http` feature. -impl From for RecipeEffort { - fn from(e: ReasoningEffort) -> Self { - match e { - ReasoningEffort::Low => RecipeEffort::Low, - ReasoningEffort::High => RecipeEffort::High, - ReasoningEffort::Xhigh => RecipeEffort::Xhigh, - ReasoningEffort::Max => RecipeEffort::Max, - } - } -} - /// SSE per-line byte cap (安全审计 P2): one `data:` line beyond this is /// a hostile stream, not a model reply. const SSE_LINE_CAP: usize = 1024 * 1024; @@ -89,7 +75,6 @@ use crate::provider::{ ChatMessage, InferError, ModelProvider, ProviderConfig, ProviderKind, ProviderOptions, ReasoningEffort, Role, ToolCall, ToolDef, ToolOutcome, }; -use deepseek_recipe_core::conversation::ReasoningEffort as RecipeEffort; use oriera_common::TokenUsage; use serde_json::{json, Value}; use std::io::{BufReader, Read}; @@ -272,9 +257,11 @@ impl HttpProvider { // (semantic exclusivity, documented on ProviderOptions). body["thinking"] = json!({ "type": "disabled" }); } else if let Some(effort) = self.options.reasoning_effort { - // A1: the recipe's canonical effort dial on the wire. - body["reasoning_effort"] = - serde_json::to_value(RecipeEffort::from(effort)).unwrap_or(Value::Null); + // 去依赖化(#106,评审共识):wire 值就是 snake_case + // 字符串,本地 as_str 与 recipe-core 序列化逐字节等价 + //(every_effort_variant_maps_to_the_recipe_snake_case + // wire 测试钉死),proc-macro 供应链面不再值得。 + body["reasoning_effort"] = serde_json::json!(effort.as_str()); } // B6: a schema supersedes plain json mode (json_object ⊆ // json_schema). Anthropic has no response_format — a @@ -716,6 +703,12 @@ impl ModelProvider for HttpProvider { self.usage.lock().map(|u| *u).unwrap_or(TokenUsage::ZERO) } + fn current_seed(&self) -> Option { + // #102: the ledger needs the run's seed identity to tell + // same-prompt/different-seed runs apart (Pinned 对账)。 + self.options.seed + } + fn determinism(&self) -> oriera_common::DeterminismLevel { // B2: seed pinned = best-effort replay; unseeded cloud calls are // recorded-only. (架构评审 P1:本 override 曾在批二 splice 中丢失、 @@ -1319,6 +1312,28 @@ mod tests { } } + #[test] + fn current_seed_reports_the_configured_option() { + // #102: ledger 对账依赖 seed 身份——HttpProvider 必须如实上报 + // ProviderOptions.seed(未配置 = None)。 + let cfg = ProviderConfig { + kind: ProviderKind::OpenAiCompat, + model: "m".into(), + api_key: "k".into(), + base_url: Some("https://example.invalid/v1".into()), + }; + let plain = HttpProvider::with_options(cfg.clone(), ProviderOptions::default()); + assert_eq!(plain.current_seed(), None); + let seeded = HttpProvider::with_options( + cfg, + ProviderOptions { + seed: Some(42), + ..ProviderOptions::default() + }, + ); + assert_eq!(seeded.current_seed(), Some(42)); + } + #[test] fn wire_options_reach_the_request_body_and_default_is_clean() { let mock = Mock::spawn(vec![ diff --git a/crates/oriera-infer/src/lib.rs b/crates/oriera-infer/src/lib.rs index e81d497..c4f803a 100644 --- a/crates/oriera-infer/src/lib.rs +++ b/crates/oriera-infer/src/lib.rs @@ -295,6 +295,24 @@ impl InferSystem { } } + /// Pin the spawn-RNG seed (#102, review E5 / issue #84): two runs + /// with the same seed and the same command stream spawn identical + /// worlds; different seeds must diverge. Without this the seed was + /// a private constant — "reproducible" only by accident of sharing + /// one hard-coded value. Also read by hosts to stamp run ledgers. + pub fn with_seed(seed: u64) -> Self { + Self { + seed, + ..Self::new() + } + } + + /// Current RNG state — hosts snapshot it into a run ledger so a + /// replay can resume from the exact point the recording stopped. + pub fn seed(&self) -> u64 { + self.seed + } + /// Submit a command for execution on the next frame. /// /// Coalesces against the queue tail when the merge is *provably* @@ -694,6 +712,36 @@ mod tests { ); } + #[test] + fn with_seed_pins_the_spawn_rng_replay() { + // #102 (#84): same seed + same stream → identical spawns; + // different seeds must diverge (此前种子是私有常量,复现只是 + // 碰巧共享同一个硬编码值)。 + fn run(seed: u64) -> (u64, u64) { + let mut sys = InferSystem::with_seed(seed); + assert_eq!(sys.seed(), seed, "seed getter reports the pinned seed"); + let mut world = World::new(); + let ctx = FrameCtx { + frame: oriera_common::FrameIndex(0), + dt: 1.0 / 60.0, + now: oriera_common::Timestamp(0), + grants: oriera_common::Grants::EMPTY, + }; + sys.submit(Command::Spawn(25)); + sys.submit(Command::Spawn(30)); + sys.run(&ctx, &mut world); + // run 后 seed() 是 LCG 推进后的当前态——同 seed 双跑推进后的 + // 状态也必须一致(run-ledger 可从该状态恢复续放)。 + (sys.seed(), world.state_hash()) + } + let (seed_end_a, h_a) = run(0xABCD); + let (seed_end_a2, h_a2) = run(0xABCD); + let (_, h_b) = run(0xABCD + 1); + assert_eq!(seed_end_a, seed_end_a2, "same seed → same RNG end state"); + assert_eq!(h_a, h_a2, "same seed → identical world state"); + assert_ne!(h_a, h_b, "different seeds → diverged spawns"); + } + #[test] fn inference_ledger_is_bounded_and_matches_entrywise() { let mut ledger = InferenceLedger::default(); diff --git a/crates/oriera-infer/src/provider.rs b/crates/oriera-infer/src/provider.rs index 80c518f..9a6095a 100644 --- a/crates/oriera-infer/src/provider.rs +++ b/crates/oriera-infer/src/provider.rs @@ -311,6 +311,16 @@ pub trait ModelProvider: Send + Sync { DeterminismLevel::Recorded } + /// The run-level sampling seed this backend honors, if any (#102, + /// review E5). Provided as `None` — seedless backends (rule/mock) + /// are strictly deterministic and need no seed identity; HTTP + /// backends built with `ProviderOptions.seed` expose it so the + /// inference ledger can distinguish same-prompt/different-seed + /// runs (Pinned 对账语义成立的前提). + fn current_seed(&self) -> Option { + None + } + /// Why this provider is a stand-in rather than the configured /// backend, if it is one. **Degrade must be visible**: hosts surface /// this in status UI and metrics instead of letting a silent From 9fcf1d6c2792db6143db93fdd953f3bfd3f64a7a Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:35:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(infer):=20=E9=A2=84=E8=AE=BE=E4=B8=89?= =?UTF-8?q?=E7=82=B9=E8=A1=A5=20doubao=EF=BC=88Studio=20api.ts=20=E5=85=88?= =?UTF-8?q?=E8=A1=8C=EF=BC=89+=20=E5=A5=91=E7=BA=A6=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=8D=87=E7=89=88=E4=B8=83=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 火山方舟 OpenAI 兼容端点(ark.cn-beijing.volces.com/api/v3), Studio api.ts 独立加行后三份拷贝再次分叉——check-presets 警告暴露, Rust 表/kernel.js/presets.json 补齐第 16 行(caps UNREGISTERED 待登记) - STUDIO-PIPELINE.md 契约升版:六区→七区(AI 素材中心走 L4 热替换+L8), demo 数改引用式表述(评审 PM-P2 契约滞后项) --- contracts/golden/presets.json | 3 +++ crates/oriera-infer/src/provider.rs | 14 +++++++++++++- docs/STUDIO-PIPELINE.md | 10 +++++++--- examples/11-ai-cafe/engine/kernel.js | 1 + 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/contracts/golden/presets.json b/contracts/golden/presets.json index 3a5c829..184bfa8 100644 --- a/contracts/golden/presets.json +++ b/contracts/golden/presets.json @@ -34,6 +34,9 @@ { "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": "doubao", "label": "豆包 · 火山方舟", "kind": "openai-compat", "model": "doubao-seed-1.6", "base": "https://ark.cn-beijing.volces.com/api/v3", "local": false, + "caps": { "contextWindow": null, "structuredOutput": false, "tools": false, "imageInput": false, "promptCache": false }, + "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 }, diff --git a/crates/oriera-infer/src/provider.rs b/crates/oriera-infer/src/provider.rs index 9a6095a..9473b6b 100644 --- a/crates/oriera-infer/src/provider.rs +++ b/crates/oriera-infer/src/provider.rs @@ -1072,6 +1072,18 @@ pub const PROVIDER_PRESETS: &[ProviderPreset] = &[ }, price: None, // 牌价待登记 }, + // doubao(火山方舟,OpenAI 兼容端点;Studio api.ts 先行,此处补齐 + // 三点对齐——presets 门禁外的第四份拷贝独立演化的活例) + ProviderPreset { + id: "doubao", + label: "豆包 · 火山方舟", + kind: ProviderKind::OpenAiCompat, + model: "doubao-seed-1.6", + base_url: "https://ark.cn-beijing.volces.com/api/v3", + local: false, + caps: ModelCaps::UNREGISTERED, // 待登记 + price: None, + }, // ── local open-source serving (no key, no egress) ── ProviderPreset { id: "ollama", @@ -1398,7 +1410,7 @@ mod preset_tests { } // 行数单一来源是 contracts/golden/presets.json(presets_match_golden_fixture // 逐行对照);这里只防「表与镜像测试失联」的低级回归。 - assert_eq!(PROVIDER_PRESETS.len(), 15); + assert_eq!(PROVIDER_PRESETS.len(), 16); } #[test] diff --git a/docs/STUDIO-PIPELINE.md b/docs/STUDIO-PIPELINE.md index af83be4..0330be5 100644 --- a/docs/STUDIO-PIPELINE.md +++ b/docs/STUDIO-PIPELINE.md @@ -33,12 +33,16 @@ OriEra Studio(壳,等价 platforms/* 的角色) └─ 仪表盘 ← L2/L5/L6/L7 的横切读数 ``` -## 1. 六区总览:界面 → 管线 → 契约 +## 1. 七区总览:界面 → 管线 → 契约 + +> v0.7.2 起实际为七区:AI 素材中心(生成页/画廊,走 L4 热替换 + L8 +> `GenerationProvider`,与 P1 建游管线交叉)已上线但未列入早期契约—— +> 本表升版补齐,契约测试以本表为准。 | Studio 区 | 界面元素 | 主管线 | 走过的层 | 契约落点 | |---|---|---|---|---| | 仪表盘 | 四张状态卡 | **P4 观测管线** | L2/L5/L6/L7 → 壳 | `system_timings` `allocator().load` `UsageMeter` `idle_frames` `quality` | -| Demo 演示厅 | 11 个内置 demo | P4(只读回放) | 全层(examples/* 静态页) | 每个 demo 只动它那一层 | +| Demo 演示厅 | 内置 demo 全集(随引擎快照,当前 13+) | P4(只读回放) | 全层(examples/* 静态页) | 每个 demo 只动它那一层 | | 新建游戏 | 五步向导 | **P1 建游管线** | L9→L7→L6→L3(+L4) | `parse` `Command` `Gravity/TimeScale` `Spawn` `insert_named` | | 模型中心 | provider 列表 + key | **P2 模型管线** | 壳→L1 route→L7 | `ProviderConfig` `ModelProvider` `route(caps,hint)` `last_usage` | | 我的项目 | 项目卡 + 存档 | P1 的持久化半边 | L3/L4 | generation 句柄稳定(T6);`insert_named/get_named` | @@ -139,7 +143,7 @@ key 的旅行路线**只有一条**:浏览器 localStorage / 壳进程环境 | ~~原生 HTTP provider 适配器~~ | 模型中心远程行 | **已落地**(#68 → PR #79,feature `native-http`,四分类错误+脱敏) | 剩余:#64 厂商认领真网验证 | | `World::serialize` | 我的项目存档 | 句柄已稳定 | v0.5,L3 manifest 已列 | | L9 `Goal` 组件 + 规划器 | 向导②「角色目标」 | `WorldStory.beats` 顶位 | v0.5–v0.6 | -| Studio 壳本体 | 全部六区 | **已上线** github.com/OriEra-engine/studio(v0.3.0) | 按本文管线契约对齐 + 补契约测试 | +| Studio 壳本体 | 全部七区 | **已上线** github.com/OriEra-engine/studio(v0.3.0) | 按本文管线契约对齐 + 补契约测试 | | wgpu 真窗口 | 仪表盘画面区 | 统计式渲染 | P2 工程计划(growth/PLAN-engineering-nov) | 一句话收束:**界面是层栈的索引——Studio 的每个按钮都能沿管线追到 diff --git a/examples/11-ai-cafe/engine/kernel.js b/examples/11-ai-cafe/engine/kernel.js index 852ab56..e3bae57 100644 --- a/examples/11-ai-cafe/engine/kernel.js +++ b/examples/11-ai-cafe/engine/kernel.js @@ -483,6 +483,7 @@ export const PROVIDER_PRESETS = { openrouter: { label: "OpenRouter", base: "https://openrouter.ai/api/v1", model: "openrouter/auto" }, anthropic: { label: "Anthropic", base: "https://api.anthropic.com/v1", model: "claude-3-5-haiku-latest", caps: { contextWindow: 200000, tools: true, imageInput: true, promptCache: true } }, + doubao: { label: "豆包 · 火山方舟", base: "https://ark.cn-beijing.volces.com/api/v3", model: "doubao-seed-1.6" }, ollama: { label: "Ollama 本地", base: "http://127.0.0.1:11434/v1", model: "qwen3:8b", local: true, caps: { structuredOutput: true, tools: true } }, vllm: { label: "vLLM 本地", base: "http://127.0.0.1:8000/v1", model: "Qwen/Qwen3-8B", local: true, From dac6779429e84aeb9994c3a49933cf2c6eb19f4b Mon Sep 17 00:00:00 2001 From: OriEra <294484954+leslie520-bye@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:43:57 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(infer):=20ReasoningEffort=20import=20?= =?UTF-8?q?=E7=A7=BB=E5=85=A5=E6=B5=8B=E8=AF=95=E8=85=BF=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8E=BB=E4=BE=9D=E8=B5=96=E5=90=8E=20lib=20=E4=BE=A7=E4=BB=85?= =?UTF-8?q?=E8=AF=BB=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=86=8D=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=EF=BC=88CI=20native-http=20clippy=20?= =?UTF-8?q?=E8=85=BF=E6=8A=93=E7=9A=84=20unused=20import=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oriera-infer/src/http.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/oriera-infer/src/http.rs b/crates/oriera-infer/src/http.rs index 4099948..8db9134 100644 --- a/crates/oriera-infer/src/http.rs +++ b/crates/oriera-infer/src/http.rs @@ -72,8 +72,8 @@ fn usage_cached(v: &Value, prompt: u64) -> u64 { } use crate::provider::{ - ChatMessage, InferError, ModelProvider, ProviderConfig, ProviderKind, ProviderOptions, - ReasoningEffort, Role, ToolCall, ToolDef, ToolOutcome, + ChatMessage, InferError, ModelProvider, ProviderConfig, ProviderKind, ProviderOptions, Role, + ToolCall, ToolDef, ToolOutcome, }; use oriera_common::TokenUsage; use serde_json::{json, Value}; @@ -962,6 +962,8 @@ impl HttpProvider { #[cfg(test)] mod tests { use super::*; + // 测试腿独用(lib 侧 wire 赋值只读字段不再需要类型 import) + use crate::provider::ReasoningEffort; use std::io::Write; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::Arc;