feat(broker): priority contexts — biases per-contexto operativo
Cierra el último pendiente de feature: el broker ahora puede operar
bajo un contexto (test/prod/foreground/secure/etc) que activa biases
declarados en las Cards.
Schema (brahman-card):
- ContextBias { pin_to: Option<String>, priority_offset: i8 }.
- Card.priority_contexts: BTreeMap<String, ContextBias>, también en
WireCard. Las conversiones From propagan el campo.
Comportamiento (brahman-broker):
- BrokerConfig.current_context: Option<String>. Cuando es Some(ctx) y
una Card tiene priority_contexts.get(ctx), el bias aplica:
- Consumer-side: bias.pin_to sobreescribe Flow.pin_to estático.
- Producer-side: bias.priority_offset se suma a la priority base
(clamp en [Low=0, Critical=3]).
- BrokeredCard propaga priority_contexts. find_producer_for usa
effective_priority y context_bias en lugar de comparar Priority
directo.
Observabilidad:
- AdminConfig.current_context + StatusSnapshot.current_context.
- brahman-status imprime "Context: <nombre>" si está activo.
Wiring:
- ente-zero lee BRAHMAN_BROKER_CONTEXT del entorno y la propaga al
broker y al admin. Sin var, biases inactivos (back-compat total).
Tests nuevos (brahman-broker, +4):
- context_priority_offset_lifts_producer_above_alphabetic_winner:
sin contexto a-prod gana por alfabético; con context "test" b-prod
gana por offset +1.
- context_pin_to_overrides_static_pin: static pin "real-dht", test
override "mock-dht" → mock gana en context "test".
- unknown_context_no_op: biases declarados para "test" no aplican
cuando broker está en "prod".
- priority_offset_clamps_to_critical: offset enorme se clampa a 3.
Validación end-to-end manual:
$ BRAHMAN_BROKER_CONTEXT=test ente-zero &
$ brahman-status
Init: server=0.1.0 protocol=0.1.0 attached=true
Context: test
Tests acumulados: 39 (card 11, broker 15, handshake codec+transport 2 +
integ 7, card-wit 4, admin 0). cargo check --workspace: 0 errores, 0
warnings.
Con esto cierran TODOS los pendientes técnicos abiertos. El único
"pendiente" que queda es el caso real para extender (priority
contexts per-deployment, scheduling biases dinámicos, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use brahman_card::{Card, Flow, Lifecycle, Priority, TypeRef, WitInterface};
|
||||
use brahman_card::{Card, ContextBias, Flow, Lifecycle, Priority, TypeRef, WitInterface};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ulid::Ulid;
|
||||
|
||||
@@ -57,6 +57,10 @@ pub enum MatchStrategy {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BrokerConfig {
|
||||
pub strategy: MatchStrategy,
|
||||
/// Contexto operativo activo. Si una Card declara un
|
||||
/// `priority_contexts.<this>`, ese bias se aplica durante el match.
|
||||
/// `None` = sin biases per-contexto, sólo se usa lo estático.
|
||||
pub current_context: Option<String>,
|
||||
}
|
||||
|
||||
/// Vista mínima de una Card que el broker necesita.
|
||||
@@ -70,6 +74,9 @@ pub struct BrokeredCard {
|
||||
pub outputs: Vec<Flow>,
|
||||
/// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico.
|
||||
pub wit: Option<WitInterface>,
|
||||
/// Biases per-contexto, propagados desde `Card.priority_contexts`.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub priority_contexts: BTreeMap<String, ContextBias>,
|
||||
}
|
||||
|
||||
impl BrokeredCard {
|
||||
@@ -82,6 +89,7 @@ impl BrokeredCard {
|
||||
inputs: card.flow.input.clone(),
|
||||
outputs: card.flow.output.clone(),
|
||||
wit,
|
||||
priority_contexts: card.priority_contexts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,10 +186,16 @@ impl Broker {
|
||||
let cons = self.cards.get(&consumer)?;
|
||||
let input = cons.inputs.iter().find(|f| f.name == input_name)?;
|
||||
|
||||
// pin_to: short-circuit. Si la pista resuelve, gana.
|
||||
if let Some(pin) = &input.pin_to {
|
||||
// pin_to efectivo: bias del contexto activo (si la Card declara
|
||||
// override consumer-side) > pin_to estático del Flow.
|
||||
let context_pin = self
|
||||
.context_bias(cons)
|
||||
.and_then(|b| b.pin_to.as_deref());
|
||||
let effective_pin = context_pin.or(input.pin_to.as_deref());
|
||||
|
||||
if let Some(pin) = effective_pin {
|
||||
for prod in self.cards.values() {
|
||||
if prod.session == consumer || &prod.label != pin {
|
||||
if prod.session == consumer || prod.label != pin {
|
||||
continue;
|
||||
}
|
||||
for out in &prod.outputs {
|
||||
@@ -205,9 +219,11 @@ impl Broker {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort por (effective priority desc, label asc). El bias del
|
||||
// contexto puede subir o bajar la priority del productor.
|
||||
candidates.sort_by(|(a, _, _), (b, _, _)| {
|
||||
b.priority
|
||||
.cmp(&a.priority)
|
||||
self.effective_priority(b)
|
||||
.cmp(&self.effective_priority(a))
|
||||
.then_with(|| a.label.cmp(&b.label))
|
||||
});
|
||||
|
||||
@@ -215,6 +231,26 @@ impl Broker {
|
||||
Some(self.make_match(cons, prod, input, out, via, false))
|
||||
}
|
||||
|
||||
/// Devuelve el `ContextBias` que aplica a este Card en el contexto
|
||||
/// activo (si lo hay).
|
||||
fn context_bias<'a>(&self, card: &'a BrokeredCard) -> Option<&'a ContextBias> {
|
||||
self.config
|
||||
.current_context
|
||||
.as_ref()
|
||||
.and_then(|ctx| card.priority_contexts.get(ctx))
|
||||
}
|
||||
|
||||
/// Priority efectiva del Card como productor, considerando el bias
|
||||
/// del contexto activo. El offset se clampa a `[Low=0, Critical=3]`.
|
||||
fn effective_priority(&self, card: &BrokeredCard) -> i16 {
|
||||
let base = priority_value(card.priority);
|
||||
let offset = self
|
||||
.context_bias(card)
|
||||
.map(|b| b.priority_offset as i16)
|
||||
.unwrap_or(0);
|
||||
(base + offset).clamp(0, 3)
|
||||
}
|
||||
|
||||
/// Calcula todos los matches consumer→producer en el set actual.
|
||||
/// Útil para introspección o para que el Admin emita rutas en lote.
|
||||
pub fn all_matches(&self) -> Vec<Match> {
|
||||
@@ -278,6 +314,15 @@ impl Broker {
|
||||
// Predicados de matching (libres, testeables aislados)
|
||||
// =====================================================================
|
||||
|
||||
fn priority_value(p: Priority) -> i16 {
|
||||
match p {
|
||||
Priority::Low => 0,
|
||||
Priority::Normal => 1,
|
||||
Priority::High => 2,
|
||||
Priority::Critical => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_match(a: &TypeRef, b: &TypeRef) -> bool {
|
||||
a == b
|
||||
}
|
||||
@@ -343,6 +388,7 @@ mod tests {
|
||||
fn exact_match_same_typeref() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Exact,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
@@ -375,6 +421,7 @@ mod tests {
|
||||
fn structural_ignores_interface() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Structural,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
@@ -413,6 +460,7 @@ mod tests {
|
||||
fn exact_strategy_rejects_interface_mismatch() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Exact,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
@@ -449,6 +497,7 @@ mod tests {
|
||||
fn exact_then_structural_prefers_exact() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::ExactThenStructural,
|
||||
current_context: None,
|
||||
});
|
||||
// Productor 1: match estructural (interface diferente)
|
||||
let p_struct = card(
|
||||
@@ -716,4 +765,210 @@ mod tests {
|
||||
assert!(pairs.contains(&("dht", "ui")));
|
||||
assert!(pairs.contains(&("ui", "dht")));
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Priority contexts
|
||||
// ===========================================================
|
||||
|
||||
#[test]
|
||||
fn context_priority_offset_lifts_producer_above_alphabetic_winner() {
|
||||
// Sin contexto, "a-prod" gana contra "b-prod" (alfabético).
|
||||
// En contexto "test", b-prod tiene offset +1 → debería ganar.
|
||||
let mut a_prod = card(
|
||||
"a-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
a_prod.priority_contexts = std::collections::BTreeMap::new(); // explícito vacío
|
||||
|
||||
let mut b_prod = card(
|
||||
"b-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
b_prod.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 1,
|
||||
},
|
||||
);
|
||||
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let s_cons = Ulid::new();
|
||||
|
||||
// Caso 1: sin contexto → a-prod gana (alfabético).
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: None,
|
||||
});
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "a-prod");
|
||||
|
||||
// Caso 2: contexto "test" → b-prod gana por offset +1.
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("test".into()),
|
||||
});
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "b-prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_pin_to_overrides_static_pin() {
|
||||
// Consumer pinea estático a "real-dht", pero en contexto "test"
|
||||
// declara override a "mock-dht".
|
||||
let real = card(
|
||||
"real-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let mock = card(
|
||||
"mock-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let mut consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), Some("real-dht"))],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
consumer.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: Some("mock-dht".into()),
|
||||
priority_offset: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let s_cons = Ulid::new();
|
||||
|
||||
// Caso 1: sin contexto → static pin gana ("real-dht").
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
b.register(Ulid::new(), &real, None);
|
||||
b.register(Ulid::new(), &mock, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "real-dht");
|
||||
assert!(m.pinned);
|
||||
|
||||
// Caso 2: contexto "test" → context override gana ("mock-dht").
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("test".into()),
|
||||
});
|
||||
b.register(Ulid::new(), &real, None);
|
||||
b.register(Ulid::new(), &mock, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "mock-dht");
|
||||
assert!(m.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_context_no_op() {
|
||||
// Si la Card declara biases para "test" pero el broker está en
|
||||
// "prod", los biases no aplican.
|
||||
let mut b_prod = card(
|
||||
"b-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
b_prod.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 5,
|
||||
},
|
||||
);
|
||||
let a_prod = card(
|
||||
"a-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("prod".into()),
|
||||
});
|
||||
let s_cons = Ulid::new();
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
// En contexto "prod" sin biases declarados, gana por alfabético.
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "a-prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_offset_clamps_to_critical() {
|
||||
// Offset enorme no debe hacer overflow ni saltar fuera del rango.
|
||||
let mut prod = card(
|
||||
"p",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
prod.priority_contexts.insert(
|
||||
"x".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 100,
|
||||
},
|
||||
);
|
||||
|
||||
let b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("x".into()),
|
||||
});
|
||||
let bc = BrokeredCard::from_card(Ulid::new(), &prod, None);
|
||||
// effective_priority debe estar clampada a 3 (Critical), no 101.
|
||||
assert_eq!(b.effective_priority(&bc), 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user