Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 3 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions contracts/golden/presets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
48 changes: 47 additions & 1 deletion crates/oriera-agent/src/cognition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ fn record_fingerprint<P: ModelProvider + ?Sized>(
) {
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()),
Expand Down Expand Up @@ -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<u64>,
}
impl ModelProvider for Seeded {
fn id(&self) -> &str {
"seeded"
}
fn complete(
&self,
messages: &[oriera_infer::ChatMessage],
) -> Result<String, oriera_infer::InferError> {
self.inner.complete(messages)
}
fn current_seed(&self) -> Option<u64> {
self.seed
}
}

fn last_ledger_prompt(seed: Option<u64>) -> 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::<oriera_infer::InferenceLedger>();
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 {
Expand Down
48 changes: 25 additions & 23 deletions crates/oriera-core/src/allocator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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<f32> = 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 {
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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);
}
}

Expand Down
67 changes: 67 additions & 0 deletions crates/oriera-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub struct Scheduler<W> {
/// 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`
Expand Down Expand Up @@ -93,6 +97,7 @@ impl<W> Scheduler<W> {
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(),
Expand Down Expand Up @@ -196,6 +201,15 @@ impl<W> Scheduler<W> {
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;
Expand Down Expand Up @@ -238,6 +252,26 @@ impl<W> Scheduler<W> {
}
}

/// 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() {
Expand Down Expand Up @@ -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<u32> = 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<u32> = Scheduler::new();
Expand Down
65 changes: 32 additions & 33 deletions crates/oriera-ecs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
//! ```

use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};

// ---------------------------------------------------------------------------
// Entity
Expand Down Expand Up @@ -311,13 +311,13 @@ impl World {
let queue = self
.events
.entry(TypeId::of::<T>())
.or_insert_with(|| Box::new(Vec::<T>::new()) as Box<dyn Any + Send + Sync>);
.or_insert_with(|| Box::new(VecDeque::<T>::new()) as Box<dyn Any + Send + Sync>);
let v = queue
.downcast_mut::<Vec<T>>()
.downcast_mut::<VecDeque<T>>()
.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::<T>(),
cap = Self::MAX_PENDING_EVENTS,
Expand All @@ -331,11 +331,11 @@ impl World {
/// only what was written after the first drain.
pub fn take_events<T: Send + Sync + 'static>(&mut self) -> Vec<T> {
match self.events.get_mut(&TypeId::of::<T>()) {
Some(queue) => std::mem::take(
queue
.downcast_mut::<Vec<T>>()
.expect("event queue keyed by TypeId of T"),
),
Some(queue) => queue
.downcast_mut::<VecDeque<T>>()
.expect("event queue keyed by TypeId of T")
.drain(..)
.collect(),
None => Vec::new(),
}
}
Expand Down Expand Up @@ -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::<Position>(),
(|any: &dyn Any| -> Vec<(usize, u64, u64)> {
column_bits::<Position>(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::<Velocity>(),
|any: &dyn Any| -> Vec<(usize, u64, u64)> {
column_bits::<Velocity>(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
Expand All @@ -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<T: 'static>(
any: &dyn Any,
words: impl Fn(&T) -> (u64, u64),
) -> Vec<(usize, u64, u64)> {
match any.downcast_ref::<Vec<Option<T>>>() {
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<T: 'static>(any: &dyn Any, h: &mut u64, words: impl Fn(&T) -> (u64, u64)) {
if let Some(col) = any.downcast_ref::<Vec<Option<T>>>() {
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::<Position>(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::<Velocity>(any, h, |v| (v.vx.to_bits() as u64, v.vy.to_bits() as u64));
}

// ---------------------------------------------------------------------------
// Engine-standard components (L3 object vocabulary)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading