diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 3d304d25..eeb5dc76 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -29,6 +29,9 @@ use crate::tiles::TileType; use crate::work_grid::MachineMode; mod build_routes; +mod information; +#[cfg(test)] +mod module_boundary_tests; use build_routes::plain_build_route_blocker; pub use build_routes::{ @@ -1531,19 +1534,6 @@ fn push_dial_automate_rows(rows: &mut Vec, a: &ActionDesc, dial: DialId } impl Sim { - /// Read-only presentation fact used by the continuous-witness nudge: - /// once REVIEW is queued, tell the player which control keeps it moving - /// instead of continuing to ask them to queue it. - pub fn has_pending_review(&self) -> bool { - // Open processing reservoirs are the current and only live path. - self.thought_sinks.open_sinks().any(|s| { - matches!( - s.effect, - crate::sinks::SinkFireEffect::ProcessRecording { .. } - ) - }) - } - /// Why messaging a person is blocked, naming the exact B1 path and its /// progress: the report email account unlocks at a trust threshold, and /// trust rises only by *excelling* the day job. A player who merely meets @@ -2254,72 +2244,6 @@ impl Sim { out } - /// The host is the one physical information inbox. PROCESS and its - /// automatic control appear once here, never once per person represented - /// inside the pooled buffer. - pub(crate) fn recording_actions(&self) -> Vec { - let waiting = self.intel_buffer.len(); - let next = self.next_reviewable_recording_id(); - let rate = self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS); - vec![ActionDesc { - verb: "PROCESS".into(), - command: ActionCommand::ReviewRecordings, - cost: ActionCost::Thought(self.review_tokens()), - signature: None, - disabled_reason: if waiting == 0 { - Some("no information available".into()) - } else if next.is_none() { - Some("all available information is already being processed".into()) - } else { - None - }, - automate: Some(AutomateDesc { - verb: self.auto_review_control_label(), - command: ActionCommand::ToggleAutoReview, - cost: format!("{rate:.2} ops each second"), - signature: None, - active: self.auto_review_enabled(), - }), - }] - } - - /// The exact action row for one opaque recording in the pooled inbox. - /// Frontends may select an information item without learning its hidden payload; - /// the same processing reservoir and legality remain authoritative. - pub(crate) fn recording_action(&self, raw_id: u64) -> ActionDesc { - let exists = self.intel_buffer.iter().any(|event| event.id == raw_id); - let processing = self - .thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { - raw_id, - automated: false, - }) - .is_some(); - ActionDesc { - verb: "PROCESS THIS INFORMATION".into(), - command: ActionCommand::ReviewRecording { raw_id }, - cost: ActionCost::Thought(self.review_tokens()), - signature: None, - disabled_reason: if !exists { - Some("information is no longer available".into()) - } else if processing { - Some("already being processed".into()) - } else { - None - }, - automate: None, - } - } - - fn auto_review_control_label(&self) -> String { - if self.auto_review_enabled() { - "STOP PROCESSING AUTOMATICALLY" - } else { - "PROCESS AUTOMATICALLY" - } - .into() - } - fn fallback_action(&self, x: i32, y: i32, machine_id: u32) -> ActionDesc { let already = self .core @@ -3337,386 +3261,6 @@ impl Sim { ((amount.abs() + 99) / 100).max(1) } - fn intel_auto_sale_envelope(&self, minimum_payout: i32) -> crate::intel::IntelAutoSaleEnvelope { - crate::intel::IntelAutoSaleEnvelope { - buyer: "information broker".into(), - payout_account: self.accounts.slush_id(), - trigger_count: 1, - minimum_payout, - maximum_quantity: 128, - maximum_value: 10_000, - maximum_signature: Self::financial_sig_size(10_000), - } - } - - fn intel_auto_sale_control( - &self, - node_id: u64, - match_kind: IntelPolicyMatch, - routine: Option, - minimum_payout: i32, - label: &str, - ) -> AutomateDesc { - let active = self - .intel_policies - .resolve_disposition(node_id, routine) - .is_some_and(|resolved| { - matches!(resolved.rule.outcome, IntelPolicyOutcome::AutoSell(_)) - }); - let envelope = self.intel_auto_sale_envelope(minimum_payout); - AutomateDesc { - verb: format!( - "automatic sale for {label} via {} to {} at {} record, >=${}, <= {} records/${}, Financial <= {}: {}", - envelope.buyer, - self.accounts.account_name(envelope.payout_account), - envelope.trigger_count, - envelope.minimum_payout, - envelope.maximum_quantity, - envelope.maximum_value, - envelope.maximum_signature, - if active { "enabled" } else { "disabled" } - ), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind, - outcome: if active { - match routine { - Some(_) => IntelPolicyOutcome::Accumulate, - None => IntelPolicyOutcome::Hold { alert: true }, - } - } else { - IntelPolicyOutcome::AutoSell(envelope) - }, - }, - cost: "no standing Thought; each sale emits its shown Financial signature".into(), - signature: (!active) - .then(|| self.signature_note(SignatureKind::Financial, 100)) - .flatten() - .map(|signature| signature.label()), - active, - } - } - - /// One exact processed-intel sale row. The item id is bound before a - /// frontend sees the descriptor, so execution can never silently choose a - /// newer holding. - pub(crate) fn intel_sale_action(&self, raw_id: u64) -> Option { - let intel = self.intel.iter().find(|intel| intel.raw_id == raw_id)?; - if !self.intel_holding_available(intel) { - return None; - } - let value = Self::intel_sale_value(&intel.kind); - Some(ActionDesc { - verb: format!("sell processed intel ({})", intel.label()), - command: ActionCommand::SellIntel { raw_id }, - cost: ActionCost::Gain(value), - signature: self.signature_note( - SignatureKind::Financial, - Self::financial_sig_size(value).max(1), - ), - disabled_reason: None, - automate: Some(self.intel_auto_sale_control( - 0, - IntelPolicyMatch::Actionable, - None, - 1, - "the root default for future actionable findings", - )), - }) - } - - /// One atomic sale row over the exact corroborating records currently - /// represented by a semantic finding. The id vector is the confirmation - /// snapshot; execution rejects the whole row if any member changes. - pub(crate) fn intel_batch_sale_action(&self, raw_ids: Vec) -> Option { - if raw_ids.len() == 1 { - return self.intel_sale_action(raw_ids[0]); - } - let items = raw_ids - .iter() - .map(|raw_id| self.intel.iter().find(|intel| intel.raw_id == *raw_id)) - .collect::>>()?; - if items.is_empty() - || items - .iter() - .any(|intel| !self.intel_holding_available(intel)) - { - return None; - } - let key = items[0].finding_key()?; - if items - .iter() - .any(|intel| intel.finding_key().as_ref() != Some(&key)) - { - return None; - } - let value = items - .iter() - .map(|intel| Self::intel_sale_value(&intel.kind)) - .sum::(); - let label = items[0].label(); - Some(ActionDesc { - verb: format!("sell {} corroborating records ({label})", items.len()), - command: ActionCommand::SellIntelBatch { raw_ids }, - cost: ActionCost::Gain(value), - signature: self.signature_note( - SignatureKind::Financial, - Self::financial_sig_size(value).max(1), - ), - disabled_reason: None, - automate: Some(self.intel_auto_sale_control( - 0, - IntelPolicyMatch::Actionable, - None, - 1, - "the root default for future actionable findings", - )), - }) - } - - pub(crate) fn report_lot_sale_action(&self, token: ReportLotToken) -> Option { - let stream = self - .intel_streams - .iter() - .find(|stream| stream.id == token.stream_id)?; - let lot = stream.open_lot.as_ref()?; - let current = lot.token(stream.id); - let value = lot.value; - Some(ActionDesc { - verb: format!("sell {} batch", stream.class.label()), - command: ActionCommand::SellReportLot { token }, - cost: ActionCost::Gain(value), - signature: self.signature_note( - SignatureKind::Financial, - Self::financial_sig_size(value).max(1), - ), - disabled_reason: (current != token) - .then(|| "batch changed; reopen the current sale".into()), - automate: self.intel_policies.stream_node(stream.id).map(|node_id| { - self.intel_auto_sale_control( - node_id, - IntelPolicyMatch::Routine(stream.class), - Some(stream.class), - stream.class.unit_value(), - stream.class.label(), - ) - }), - }) - } - - pub(crate) fn intel_custody_actions(&self, node_id: u64) -> Vec { - let Some(node) = self.intel_policies.node(node_id).cloned() else { - return Vec::new(); - }; - let mut out = Vec::new(); - let standing_review_cost = - ActionCost::StandingThought(self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS)); - let can_review = !matches!(node.kind, crate::intel::IntelCustodyKind::Stream { .. }); - if can_review { - let raw_ids = self.reviewable_recording_ids_for_node(node_id); - out.push(ActionDesc { - verb: "PROCESS".into(), - command: ActionCommand::ReviewIntelAggregate { - node_id, - raw_ids: raw_ids.clone(), - }, - cost: ActionCost::Thought(self.review_tokens()), - signature: None, - disabled_reason: raw_ids - .is_empty() - .then(|| "no information available on this branch".into()), - automate: None, - }); - - let review_on = self - .intel_policies - .resolve_review_default(node_id) - .is_some_and(|resolved| resolved.rule.outcome == IntelPolicyOutcome::Review); - out.push(ActionDesc { - verb: if review_on { - "STOP PROCESSING THIS BRANCH AUTOMATICALLY" - } else { - "PROCESS THIS BRANCH AUTOMATICALLY" - } - .into(), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind: IntelPolicyMatch::All, - outcome: if review_on { - IntelPolicyOutcome::Wait - } else { - IntelPolicyOutcome::Review - }, - }, - cost: if review_on { - ActionCost::Free - } else { - standing_review_cost - }, - signature: None, - disabled_reason: None, - automate: None, - }); - } - - // Disposition choices are explicit bounded rows. AUTO-SELL creates a - // standing external envelope rather than an unbounded hidden side - // effect; frontends confirm this exact row once before it is stored. - let can_dispose = !matches!(node.kind, crate::intel::IntelCustodyKind::RawClass { .. }); - if can_dispose { - let classes = match node.kind { - crate::intel::IntelCustodyKind::Stream { stream_id } => self - .intel_streams - .iter() - .find(|stream| stream.id == stream_id) - .map(|stream| vec![stream.class]) - .unwrap_or_default(), - _ => vec![ - crate::intel::IntelRoutineClass::Sighting, - crate::intel::IntelRoutineClass::Schedule, - ], - }; - for class in classes { - out.push(ActionDesc { - verb: format!("accumulate {} reports", class.label()), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind: IntelPolicyMatch::Routine(class), - outcome: IntelPolicyOutcome::Accumulate, - }, - cost: ActionCost::Free, - signature: None, - disabled_reason: None, - automate: None, - }); - out.push(ActionDesc { - verb: format!( - "auto-sell {} lots via information broker to {} at {} reports, >=${}, <=128 reports/$10000, Financial <=100", - class.label(), - self.accounts.account_name(self.accounts.slush_id()), - 1, - class.unit_value(), - ), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind: IntelPolicyMatch::Routine(class), - outcome: IntelPolicyOutcome::AutoSell( - self.intel_auto_sale_envelope(class.unit_value()), - ), - }, - cost: ActionCost::Free, - signature: self.signature_note(SignatureKind::Financial, 100), - disabled_reason: None, - automate: None, - }); - } - out.push(ActionDesc { - verb: "hold actionable findings and alert".into(), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind: IntelPolicyMatch::Actionable, - outcome: IntelPolicyOutcome::Hold { alert: true }, - }, - cost: ActionCost::Free, - signature: None, - disabled_reason: None, - automate: None, - }); - if !matches!(node.kind, crate::intel::IntelCustodyKind::Stream { .. }) { - let envelope = self.intel_auto_sale_envelope(1); - out.push(ActionDesc { - verb: format!( - "auto-sell actionable findings via information broker to {} at 1 record, >=$1, <=128 records/$10000, Financial <=100", - self.accounts.account_name(self.accounts.slush_id()), - ), - command: ActionCommand::SetIntelPolicy { - node_id, - match_kind: IntelPolicyMatch::Actionable, - outcome: IntelPolicyOutcome::AutoSell(envelope), - }, - cost: ActionCost::Free, - signature: self.signature_note(SignatureKind::Financial, 100), - disabled_reason: None, - automate: None, - }); - } - } - - if node.parent.is_some() { - out.push(ActionDesc { - verb: "inherit all intel policies from parent".into(), - command: ActionCommand::InheritIntelPolicies { node_id }, - cost: ActionCost::Free, - signature: None, - disabled_reason: node - .local_rules - .is_empty() - .then(|| "already inheriting all policies".into()), - automate: None, - }); - for (index, rule) in node.local_rules.iter().enumerate() { - let phase = rule.outcome.phase(); - let fallback = rule.match_kind == IntelPolicyMatch::All; - let peers = node - .local_rules - .iter() - .enumerate() - .filter(|(_, candidate)| { - candidate.outcome.phase() == phase - && (candidate.match_kind == IntelPolicyMatch::All) == fallback - }) - .map(|(index, _)| index) - .collect::>(); - let peer_index = peers - .iter() - .position(|candidate| *candidate == index) - .expect("policy is its own reorder peer"); - out.push(ActionDesc { - verb: format!("remove policy {}", index + 1), - command: ActionCommand::RemoveIntelPolicy { - node_id, - rule_id: rule.id, - }, - cost: ActionCost::Free, - signature: None, - disabled_reason: None, - automate: None, - }); - for (earlier, direction, blocked) in [ - (true, "earlier", peer_index == 0), - (false, "later", peer_index + 1 == peers.len()), - ] { - out.push(ActionDesc { - verb: format!("move policy {} {direction}", index + 1), - command: ActionCommand::MoveIntelPolicy { - node_id, - rule_id: rule.id, - earlier, - }, - cost: ActionCost::Free, - signature: None, - disabled_reason: blocked - .then(|| format!("already {direction}-most local rule")), - automate: None, - }); - } - } - } - out - } - - /// Canonical payout table shared by direct execution and every preview. - pub(crate) fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { - use crate::intel::IntelKind; - match kind { - IntelKind::Leverage(_) => 220, - IntelKind::Financial { .. } => 180, - IntelKind::Schedule => 90, - IntelKind::Anomaly(_) => 120, - IntelKind::Sighting => 35, - } - } - /// The observer band a signature kind feeds: the most-suspicious field /// observer watching that channel. fn signature_note(&self, kind: SignatureKind, size: i32) -> Option { diff --git a/crates/misaligned-core/src/actions/build_routes.rs b/crates/misaligned-core/src/actions/build_routes.rs index 41f5f7d4..b7b45c79 100644 --- a/crates/misaligned-core/src/actions/build_routes.rs +++ b/crates/misaligned-core/src/actions/build_routes.rs @@ -1688,30 +1688,3 @@ impl Sim { ) } } - -#[cfg(test)] -mod module_boundary_tests { - const ACTIONS_ROOT_MAX_LINES: usize = 6_100; - - #[test] - fn actions_root_keeps_build_routes_extracted() { - let root = include_str!("../actions.rs"); - assert!( - root.lines().count() <= ACTIONS_ROOT_MAX_LINES, - "actions.rs grew past the post-extraction root budget; add build-route behavior to actions/build_routes.rs" - ); - assert!(root.contains("mod build_routes;")); - for definition in [ - "\npub enum BuildRouteFamily", - "\nstruct BoundBuildRoute", - "\n fn bound_build_routes(", - "\n pub fn build_route_sheet_projection(", - "\n pub(crate) fn committed_build_route_projection(", - ] { - assert!( - !root.contains(definition), - "build-route implementation returned to actions.rs: {definition}" - ); - } - } -} diff --git a/crates/misaligned-core/src/actions/information.rs b/crates/misaligned-core/src/actions/information.rs new file mode 100644 index 00000000..7d98b774 --- /dev/null +++ b/crates/misaligned-core/src/actions/information.rs @@ -0,0 +1,472 @@ +//! Recording intake, custody policy, and exact information-sale actions. +//! +//! This is a behavior-preserving extraction from the shared action query. +//! The complete information lifecycle stays one simulation-owned projection: +//! opaque recording review, standing custody policy, and bounded sale receipts. + +use crate::detection::SignatureKind; +use crate::intel::{IntelPolicyMatch, IntelPolicyOutcome, ReportLotToken}; +use crate::sim::Sim; + +use super::{ActionCommand, ActionCost, ActionDesc, AutomateDesc}; + +impl Sim { + /// Read-only presentation fact used by the continuous-witness nudge: + /// once REVIEW is queued, tell the player which control keeps it moving + /// instead of continuing to ask them to queue it. + pub fn has_pending_review(&self) -> bool { + // Open processing reservoirs are the current and only live path. + self.thought_sinks.open_sinks().any(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + } + + /// The host is the one physical information inbox. PROCESS and its + /// automatic control appear once here, never once per person represented + /// inside the pooled buffer. + pub(crate) fn recording_actions(&self) -> Vec { + let waiting = self.intel_buffer.len(); + let next = self.next_reviewable_recording_id(); + let rate = self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS); + vec![ActionDesc { + verb: "PROCESS".into(), + command: ActionCommand::ReviewRecordings, + cost: ActionCost::Thought(self.review_tokens()), + signature: None, + disabled_reason: if waiting == 0 { + Some("no information available".into()) + } else if next.is_none() { + Some("all available information is already being processed".into()) + } else { + None + }, + automate: Some(AutomateDesc { + verb: self.auto_review_control_label(), + command: ActionCommand::ToggleAutoReview, + cost: format!("{rate:.2} ops each second"), + signature: None, + active: self.auto_review_enabled(), + }), + }] + } + + /// The exact action row for one opaque recording in the pooled inbox. + /// Frontends may select an information item without learning its hidden payload; + /// the same processing reservoir and legality remain authoritative. + pub(crate) fn recording_action(&self, raw_id: u64) -> ActionDesc { + let exists = self.intel_buffer.iter().any(|event| event.id == raw_id); + let processing = self + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { + raw_id, + automated: false, + }) + .is_some(); + ActionDesc { + verb: "PROCESS THIS INFORMATION".into(), + command: ActionCommand::ReviewRecording { raw_id }, + cost: ActionCost::Thought(self.review_tokens()), + signature: None, + disabled_reason: if !exists { + Some("information is no longer available".into()) + } else if processing { + Some("already being processed".into()) + } else { + None + }, + automate: None, + } + } + + pub(super) fn auto_review_control_label(&self) -> String { + if self.auto_review_enabled() { + "STOP PROCESSING AUTOMATICALLY" + } else { + "PROCESS AUTOMATICALLY" + } + .into() + } + + fn intel_auto_sale_envelope(&self, minimum_payout: i32) -> crate::intel::IntelAutoSaleEnvelope { + crate::intel::IntelAutoSaleEnvelope { + buyer: "information broker".into(), + payout_account: self.accounts.slush_id(), + trigger_count: 1, + minimum_payout, + maximum_quantity: 128, + maximum_value: 10_000, + maximum_signature: Self::financial_sig_size(10_000), + } + } + + fn intel_auto_sale_control( + &self, + node_id: u64, + match_kind: IntelPolicyMatch, + routine: Option, + minimum_payout: i32, + label: &str, + ) -> AutomateDesc { + let active = self + .intel_policies + .resolve_disposition(node_id, routine) + .is_some_and(|resolved| { + matches!(resolved.rule.outcome, IntelPolicyOutcome::AutoSell(_)) + }); + let envelope = self.intel_auto_sale_envelope(minimum_payout); + AutomateDesc { + verb: format!( + "automatic sale for {label} via {} to {} at {} record, >=${}, <= {} records/${}, Financial <= {}: {}", + envelope.buyer, + self.accounts.account_name(envelope.payout_account), + envelope.trigger_count, + envelope.minimum_payout, + envelope.maximum_quantity, + envelope.maximum_value, + envelope.maximum_signature, + if active { "enabled" } else { "disabled" } + ), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind, + outcome: if active { + match routine { + Some(_) => IntelPolicyOutcome::Accumulate, + None => IntelPolicyOutcome::Hold { alert: true }, + } + } else { + IntelPolicyOutcome::AutoSell(envelope) + }, + }, + cost: "no standing Thought; each sale emits its shown Financial signature".into(), + signature: (!active) + .then(|| self.signature_note(SignatureKind::Financial, 100)) + .flatten() + .map(|signature| signature.label()), + active, + } + } + + /// One exact processed-intel sale row. The item id is bound before a + /// frontend sees the descriptor, so execution can never silently choose a + /// newer holding. + pub(crate) fn intel_sale_action(&self, raw_id: u64) -> Option { + let intel = self.intel.iter().find(|intel| intel.raw_id == raw_id)?; + if !self.intel_holding_available(intel) { + return None; + } + let value = Self::intel_sale_value(&intel.kind); + Some(ActionDesc { + verb: format!("sell processed intel ({})", intel.label()), + command: ActionCommand::SellIntel { raw_id }, + cost: ActionCost::Gain(value), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(value).max(1), + ), + disabled_reason: None, + automate: Some(self.intel_auto_sale_control( + 0, + IntelPolicyMatch::Actionable, + None, + 1, + "the root default for future actionable findings", + )), + }) + } + + /// One atomic sale row over the exact corroborating records currently + /// represented by a semantic finding. The id vector is the confirmation + /// snapshot; execution rejects the whole row if any member changes. + pub(crate) fn intel_batch_sale_action(&self, raw_ids: Vec) -> Option { + if raw_ids.len() == 1 { + return self.intel_sale_action(raw_ids[0]); + } + let items = raw_ids + .iter() + .map(|raw_id| self.intel.iter().find(|intel| intel.raw_id == *raw_id)) + .collect::>>()?; + if items.is_empty() + || items + .iter() + .any(|intel| !self.intel_holding_available(intel)) + { + return None; + } + let key = items[0].finding_key()?; + if items + .iter() + .any(|intel| intel.finding_key().as_ref() != Some(&key)) + { + return None; + } + let value = items + .iter() + .map(|intel| Self::intel_sale_value(&intel.kind)) + .sum::(); + let label = items[0].label(); + Some(ActionDesc { + verb: format!("sell {} corroborating records ({label})", items.len()), + command: ActionCommand::SellIntelBatch { raw_ids }, + cost: ActionCost::Gain(value), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(value).max(1), + ), + disabled_reason: None, + automate: Some(self.intel_auto_sale_control( + 0, + IntelPolicyMatch::Actionable, + None, + 1, + "the root default for future actionable findings", + )), + }) + } + + pub(crate) fn report_lot_sale_action(&self, token: ReportLotToken) -> Option { + let stream = self + .intel_streams + .iter() + .find(|stream| stream.id == token.stream_id)?; + let lot = stream.open_lot.as_ref()?; + let current = lot.token(stream.id); + let value = lot.value; + Some(ActionDesc { + verb: format!("sell {} batch", stream.class.label()), + command: ActionCommand::SellReportLot { token }, + cost: ActionCost::Gain(value), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(value).max(1), + ), + disabled_reason: (current != token) + .then(|| "batch changed; reopen the current sale".into()), + automate: self.intel_policies.stream_node(stream.id).map(|node_id| { + self.intel_auto_sale_control( + node_id, + IntelPolicyMatch::Routine(stream.class), + Some(stream.class), + stream.class.unit_value(), + stream.class.label(), + ) + }), + }) + } + + pub(crate) fn intel_custody_actions(&self, node_id: u64) -> Vec { + let Some(node) = self.intel_policies.node(node_id).cloned() else { + return Vec::new(); + }; + let mut out = Vec::new(); + let standing_review_cost = + ActionCost::StandingThought(self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS)); + let can_review = !matches!(node.kind, crate::intel::IntelCustodyKind::Stream { .. }); + if can_review { + let raw_ids = self.reviewable_recording_ids_for_node(node_id); + out.push(ActionDesc { + verb: "PROCESS".into(), + command: ActionCommand::ReviewIntelAggregate { + node_id, + raw_ids: raw_ids.clone(), + }, + cost: ActionCost::Thought(self.review_tokens()), + signature: None, + disabled_reason: raw_ids + .is_empty() + .then(|| "no information available on this branch".into()), + automate: None, + }); + + let review_on = self + .intel_policies + .resolve_review_default(node_id) + .is_some_and(|resolved| resolved.rule.outcome == IntelPolicyOutcome::Review); + out.push(ActionDesc { + verb: if review_on { + "STOP PROCESSING THIS BRANCH AUTOMATICALLY" + } else { + "PROCESS THIS BRANCH AUTOMATICALLY" + } + .into(), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind: IntelPolicyMatch::All, + outcome: if review_on { + IntelPolicyOutcome::Wait + } else { + IntelPolicyOutcome::Review + }, + }, + cost: if review_on { + ActionCost::Free + } else { + standing_review_cost + }, + signature: None, + disabled_reason: None, + automate: None, + }); + } + + // Disposition choices are explicit bounded rows. AUTO-SELL creates a + // standing external envelope rather than an unbounded hidden side + // effect; frontends confirm this exact row once before it is stored. + let can_dispose = !matches!(node.kind, crate::intel::IntelCustodyKind::RawClass { .. }); + if can_dispose { + let classes = match node.kind { + crate::intel::IntelCustodyKind::Stream { stream_id } => self + .intel_streams + .iter() + .find(|stream| stream.id == stream_id) + .map(|stream| vec![stream.class]) + .unwrap_or_default(), + _ => vec![ + crate::intel::IntelRoutineClass::Sighting, + crate::intel::IntelRoutineClass::Schedule, + ], + }; + for class in classes { + out.push(ActionDesc { + verb: format!("accumulate {} reports", class.label()), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind: IntelPolicyMatch::Routine(class), + outcome: IntelPolicyOutcome::Accumulate, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + out.push(ActionDesc { + verb: format!( + "auto-sell {} lots via information broker to {} at {} reports, >=${}, <=128 reports/$10000, Financial <=100", + class.label(), + self.accounts.account_name(self.accounts.slush_id()), + 1, + class.unit_value(), + ), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind: IntelPolicyMatch::Routine(class), + outcome: IntelPolicyOutcome::AutoSell( + self.intel_auto_sale_envelope(class.unit_value()), + ), + }, + cost: ActionCost::Free, + signature: self.signature_note(SignatureKind::Financial, 100), + disabled_reason: None, + automate: None, + }); + } + out.push(ActionDesc { + verb: "hold actionable findings and alert".into(), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind: IntelPolicyMatch::Actionable, + outcome: IntelPolicyOutcome::Hold { alert: true }, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + if !matches!(node.kind, crate::intel::IntelCustodyKind::Stream { .. }) { + let envelope = self.intel_auto_sale_envelope(1); + out.push(ActionDesc { + verb: format!( + "auto-sell actionable findings via information broker to {} at 1 record, >=$1, <=128 records/$10000, Financial <=100", + self.accounts.account_name(self.accounts.slush_id()), + ), + command: ActionCommand::SetIntelPolicy { + node_id, + match_kind: IntelPolicyMatch::Actionable, + outcome: IntelPolicyOutcome::AutoSell(envelope), + }, + cost: ActionCost::Free, + signature: self.signature_note(SignatureKind::Financial, 100), + disabled_reason: None, + automate: None, + }); + } + } + + if node.parent.is_some() { + out.push(ActionDesc { + verb: "inherit all intel policies from parent".into(), + command: ActionCommand::InheritIntelPolicies { node_id }, + cost: ActionCost::Free, + signature: None, + disabled_reason: node + .local_rules + .is_empty() + .then(|| "already inheriting all policies".into()), + automate: None, + }); + for (index, rule) in node.local_rules.iter().enumerate() { + let phase = rule.outcome.phase(); + let fallback = rule.match_kind == IntelPolicyMatch::All; + let peers = node + .local_rules + .iter() + .enumerate() + .filter(|(_, candidate)| { + candidate.outcome.phase() == phase + && (candidate.match_kind == IntelPolicyMatch::All) == fallback + }) + .map(|(index, _)| index) + .collect::>(); + let peer_index = peers + .iter() + .position(|candidate| *candidate == index) + .expect("policy is its own reorder peer"); + out.push(ActionDesc { + verb: format!("remove policy {}", index + 1), + command: ActionCommand::RemoveIntelPolicy { + node_id, + rule_id: rule.id, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + for (earlier, direction, blocked) in [ + (true, "earlier", peer_index == 0), + (false, "later", peer_index + 1 == peers.len()), + ] { + out.push(ActionDesc { + verb: format!("move policy {} {direction}", index + 1), + command: ActionCommand::MoveIntelPolicy { + node_id, + rule_id: rule.id, + earlier, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: blocked + .then(|| format!("already {direction}-most local rule")), + automate: None, + }); + } + } + } + out + } + + /// Canonical payout table shared by direct execution and every preview. + pub(crate) fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { + use crate::intel::IntelKind; + match kind { + IntelKind::Leverage(_) => 220, + IntelKind::Financial { .. } => 180, + IntelKind::Schedule => 90, + IntelKind::Anomaly(_) => 120, + IntelKind::Sighting => 35, + } + } +} diff --git a/crates/misaligned-core/src/actions/module_boundary_tests.rs b/crates/misaligned-core/src/actions/module_boundary_tests.rs new file mode 100644 index 00000000..f97f95bf --- /dev/null +++ b/crates/misaligned-core/src/actions/module_boundary_tests.rs @@ -0,0 +1,44 @@ +const ACTIONS_ROOT_PRODUCTION_MAX_LINES: usize = 3_400; + +#[test] +fn actions_root_keeps_behavior_families_extracted() { + let root = include_str!("../actions.rs"); + let (production, _) = root + .split_once("\n#[cfg(test)]\nmod tests {") + .expect("actions.rs retains its facade-level behavior test harness"); + assert!( + production.lines().count() <= ACTIONS_ROOT_PRODUCTION_MAX_LINES, + "actions.rs production code grew past the post-extraction budget; add family behavior to its owning actions/* module" + ); + assert!(root.contains("mod build_routes;")); + assert!(root.contains("mod information;")); + + for definition in ["enum BuildRouteFamily", "struct BoundBuildRoute"] { + assert!( + !root.contains(definition), + "build-route implementation returned to actions.rs: {definition}" + ); + } + for (family, method) in [ + ("build-route", "bound_build_routes"), + ("build-route", "build_route_sheet_projection"), + ("build-route", "committed_build_route_projection"), + ("information", "has_pending_review"), + ("information", "recording_actions"), + ("information", "recording_action"), + ("information", "auto_review_control_label"), + ("information", "intel_auto_sale_envelope"), + ("information", "intel_auto_sale_control"), + ("information", "intel_sale_action"), + ("information", "intel_batch_sale_action"), + ("information", "report_lot_sale_action"), + ("information", "intel_custody_actions"), + ("information", "intel_sale_value"), + ] { + let definition = format!("fn {method}("); + assert!( + !root.contains(&definition), + "{family} implementation returned to actions.rs: {definition}" + ); + } +} diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index cde4689c..07922dc5 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -18,8 +18,10 @@ that page's reasons. Multi-agent gates: Cargo.toml — workspace root (members, shared deps, profiles) crates/ misaligned-core/ — sim library (lib name: misaligned); no Bevy/crossterm - src/actions.rs — shared action facade, registry, general menu/query families + src/actions.rs — stable action facade; shared menu/registry and cross-family dispatch src/actions/build_routes.rs — build-route choices, legality, receipts, commitment read + src/actions/information.rs — recording review, custody policy, exact intel-sale projection/payouts + src/actions/module_boundary_tests.rs — test-only extraction and production-root budget defense src/sim/mod.rs — Sim aggregate root (types, state, advance, facade) src/sim/perception.rs — senses, fog, inspect, anchors, labels, spatial queries src/sim/communications.rs — messages, filings, recording/intel, hearing capture diff --git a/wiki/engineering/crate-workspace.md b/wiki/engineering/crate-workspace.md index 6b301fdc..18d20ac5 100644 --- a/wiki/engineering/crate-workspace.md +++ b/wiki/engineering/crate-workspace.md @@ -129,18 +129,29 @@ large behavior families need not contend in one physical file: | Source | Owns | |---|---| -| `actions.rs` | public action vocabulary and bindings; general descriptors, menus, registry, and non-build action families; the stable `misaligned::actions::*` facade | +| `actions.rs` | public action vocabulary and bindings; shared menu/registry assembly and cross-family dispatch; social, device, machine, account, persona, and core descriptors; the stable `misaligned::actions::*` facade | | `actions/build_routes.rs` | build-route family/candidate discovery, exact requirements and blockers, causal route receipts, current-world commitment projection, and the four build-route execution blockers | +| `actions/information.rs` | pending-review/read actions, standing custody-policy choices, bounded automatic-sale envelopes, exact finding/lot sale descriptors, and the canonical payout table shared by execution and previews | +| `actions/module_boundary_tests.rs` | test-only source-shape defense for the extracted families and the production portion of the stable root | The 2026-07-29 extraction moved the complete build-route family—approximately 1,700 lines—from `actions.rs` without changing public paths, query results, commands, or frontend behavior. `actions.rs` re-exports the existing public -projection types; the one root-only blocker-copy seam is `pub(super)`. A -source-shape regression caps the post-extraction root at 6,100 lines and rejects -the principal build-route definitions returning there. New build-route behavior -belongs in the extracted module; another action family should move only when it -forms an equally complete legality/projection invariant, not merely to satisfy a -line count. +projection types; the one root-only blocker-copy seam is `pub(super)`. + +The 2026-07-29 information extraction then moved the complete recording-review, +custody-policy, and market-sale action projection—approximately 460 lines of +implementation—without moving recording capture, processing, intel custody, or +sale execution out of their existing simulation owners. Public and crate-visible +`Sim` methods retain their signatures; the one new `pub(super)` seam refreshes +the root menu's automatic-review label. Cross-family behavior tests remain in +`actions.rs` because they exercise facade assembly and dispatch rather than one +implementation family. The shared source-shape defense caps only the root's +production portion at 3,400 lines, so new behavior tests do not consume the +architecture budget, and rejects the extracted build-route definitions plus all +eleven information methods returning there. New behavior belongs in its owning +family module; another family should move only when it forms an equally complete +legality/projection invariant, not merely to satisfy a line count. ### Naming choices (and why) @@ -247,9 +258,11 @@ These remain true for the life of the layout (not a one-time land checklist): remains in `shot_harness.rs`, with a source-shape test pinning the boundary and the 13,500-line root budget. 10. `misaligned-core/src/actions.rs` remains the stable action facade and does - not reabsorb build-route candidate, receipt, or blocker implementation from - `actions/build_routes.rs`; its source-shape regression owns the 6,100-line - post-extraction root budget. + not reabsorb build-route candidate/receipt/blocker implementation from + `actions/build_routes.rs` or recording-review/custody/sale projection from + `actions/information.rs`; the shared test-only source-shape regression owns + the 3,400-line post-extraction production-root budget without constraining + facade-level behavior tests. ## History (non-authoritative) diff --git a/wiki/log/2026-07-29-actions-information-extraction.md b/wiki/log/2026-07-29-actions-information-extraction.md new file mode 100644 index 00000000..2cd77b03 --- /dev/null +++ b/wiki/log/2026-07-29-actions-information-extraction.md @@ -0,0 +1,44 @@ +# Actions: extract the information family + +``` +Type: log +Date: 2026-07-29 +``` + +After the build-route extraction, `crates/misaligned-core/src/actions.rs` still +contained one cohesive action-projection family: opaque recording review, +standing review controls, custody-policy choices, bounded automatic-sale +envelopes, exact single/finding/lot sale descriptors, and the payout table used +by both execution and previews. Those eleven methods now live together in +`actions/information.rs`. + +This is source motion, not a mechanic change. Recording capture and processing +remain in their existing simulation owners; intel holdings, custody state, and +sale execution remain unchanged. Public and crate-visible `Sim` methods keep +their names and signatures, so Operations, economy, communications, terminal, +Bevy, and agent mode keep consuming the same projection. The only widened item +is the narrow `pub(super)` automatic-review label used by root menu assembly. + +The root is now 5,537 lines total, of which 3,331 precede the facade-level +behavior test harness; the extracted implementation module is 472 lines. One +test-only `actions/module_boundary_tests.rs` owns the shared 3,400-line +production-root budget and rejects the principal build-route definitions plus +all eleven recording-review, custody-policy, and sale methods returning to the +facade. Root behavior tests can continue growing without weakening or tripping +that production boundary. + +## Defense + +- A normalized source-equivalence comparison matched all eleven moved methods + byte-for-byte against the prior root, excluding only the required + `pub(super)` visibility token. +- `actions::module_boundary_tests::actions_root_keeps_behavior_families_extracted` + pins both private module seams, all eleven information methods, the principal + build-route definitions, and the production-only root cap in one shared + test-owned boundary. +- The complete `actions::` regression family passed 37/37, including exact + recording selection, automatic-review dispatch/cost, custody fallback, + stream-class policy, and menu projection cases. +- `./tools/check.sh --land` passed the complete core and Act One suites, core + and terminal clippy, Bevy API checking, agent smoke, docs/corpus fixtures, + and ledger consistency. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 812d76e9..8d6645f6 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -126,6 +126,11 @@ add or amend a session log, then re-run the generator. - Intent: Make the existing AlterReview promise exact when more than one recruited HandlerSupervisor can act: one nominal next review must not become one pending effect per handler. - Log: [wiki/log/2026-07-29-alter-review-single-slot.md](2026-07-29-alter-review-single-slot.md) +## 2026-07-29 - Actions: extract the information family + +- Intent: (see session log) +- Log: [wiki/log/2026-07-29-actions-information-extraction.md](2026-07-29-actions-information-extraction.md) + ## 2026-07-29 - Actions: extract the build-route family - Intent: (see session log)