use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use scema_agent::{Agent, Cycle};
use scema_memory::{Calibration, MemoryKind};
use scema_policy::DecisionConfig;
use scema_verify::{verify, DecisionRecord, RecordStore, Verification};
use scema_world::{Constraint, Goal, WorldState};
use crate::theme::Theme;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tab {
World,
Simulate,
Records,
Memory,
Policy,
}
impl Tab {
pub const ALL: [Tab; 5] = [Tab::World, Tab::Simulate, Tab::Records, Tab::Memory, Tab::Policy];
pub fn title(self) -> &'static str {
match self {
Tab::World => "WORLD",
Tab::Simulate => "SIMULATE",
Tab::Records => "RECORDS",
Tab::Memory => "MEMORY",
Tab::Policy => "POLICY",
}
}
pub fn question(self) -> &'static str {
match self {
Tab::World => "what is out there, and what could not be seen?",
Tab::Simulate => "which branch wins, and does anything measured support it?",
Tab::Records => "what was decided, and does the record still verify?",
Tab::Memory => "what has been retained, and how well did it project?",
Tab::Policy => "under whose preferences, and with which specialists?",
}
}
pub fn next(self) -> Tab {
let i = Tab::ALL.iter().position(|t| *t == self).unwrap_or(0);
Tab::ALL[(i + 1) % Tab::ALL.len()]
}
pub fn prev(self) -> Tab {
let i = Tab::ALL.iter().position(|t| *t == self).unwrap_or(0);
Tab::ALL[(i + Tab::ALL.len() - 1) % Tab::ALL.len()]
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Left,
Right,
}
impl Focus {
pub fn flip(self) -> Focus {
match self {
Focus::Left => Focus::Right,
Focus::Right => Focus::Left,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Normal,
EditGoal,
EditConstraint,
ConfirmDecide,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Idle,
Note(String),
Warn(String),
Error(String),
}
impl Status {
pub fn text(&self) -> &str {
match self {
Status::Idle => "",
Status::Note(s) | Status::Warn(s) | Status::Error(s) => s,
}
}
}
#[derive(Clone, Debug)]
pub struct RecordRow {
pub id: String,
pub at: i64,
pub goal: String,
pub entity: String,
pub chosen: Option<String>,
pub outcome: String,
pub valid: Option<bool>,
pub unreadable: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MemoryView {
pub counts: Vec<(MemoryKind, usize, usize)>,
pub calibration: Calibration,
pub root: PathBuf,
}
pub enum Job {
Observe { path: String },
Cycle { world: Box<WorldState>, goal: Box<Goal>, persist: bool },
LoadRecords,
OpenRecord(String),
LoadMemory,
}
pub enum Done {
Observed(Result<Box<WorldState>, String>),
Cycled(Result<Box<Cycle>, String>),
Records(Result<Vec<RecordRow>, String>),
Opened(Result<Box<(DecisionRecord, Verification)>, String>),
Memory(Result<Box<MemoryView>, String>),
}
pub struct Worker {
tx: Sender<Job>,
pub rx: Receiver<Done>,
}
impl Worker {
pub fn spawn(agent: Arc<Agent>, root: PathBuf) -> Worker {
let (tx, jobs) = mpsc::channel::<Job>();
let (results, rx) = mpsc::channel::<Done>();
std::thread::spawn(move || {
for job in jobs {
let out = match job {
Job::Observe { path } => Done::Observed(
agent.observe(&path).map(Box::new).map_err(|e| format!("{e:#}")),
),
Job::Cycle { world, goal, persist } => {
let result = if persist {
agent.cycle_over(*world, *goal)
} else {
let mut dry = Agent::new(root.clone(), None);
dry.persist = false;
dry.config = agent.config;
dry.cycle_over(*world, *goal)
};
Done::Cycled(result.map(Box::new).map_err(|e| format!("{e:#}")))
}
Job::LoadRecords => Done::Records(load_records(&root)),
Job::OpenRecord(id) => Done::Opened(
RecordStore::new(root.clone())
.load(&id)
.map(|r| {
let v = verify(&r);
Box::new((r, v))
})
.map_err(|e| format!("{e:#}")),
),
Job::LoadMemory => Done::Memory(load_memory(&agent).map(Box::new)),
};
if results.send(out).is_err() {
break; }
}
});
Worker { tx, rx }
}
pub fn send(&self, job: Job) {
let _ = self.tx.send(job);
}
}
fn load_records(root: &Path) -> Result<Vec<RecordRow>, String> {
let store = RecordStore::new(root.to_path_buf());
let ids = store.ids().map_err(|e| format!("{e:#}"))?;
Ok(ids
.iter()
.map(|id| match store.load(id) {
Ok(r) => {
let v = verify(&r);
RecordRow {
id: r.id.clone(),
at: r.at,
goal: r.goal.statement.clone(),
entity: r.world.entity.locator.clone(),
chosen: r.decision.chosen.clone(),
outcome: match (&r.decision.chosen, &r.decision.abstention) {
(Some(c), _) => format!("chose {c}"),
(None, Some(a)) => a.headline(),
_ => "no decision and no reason".into(),
},
valid: Some(v.valid),
unreadable: None,
}
}
Err(e) => RecordRow {
id: id.clone(),
at: 0,
goal: String::new(),
entity: String::new(),
chosen: None,
outcome: String::new(),
valid: None,
unreadable: Some(format!("{e:#}")),
},
})
.collect())
}
fn load_memory(agent: &Agent) -> Result<MemoryView, String> {
let mem = agent.memory();
Ok(MemoryView {
counts: mem.counts().map_err(|e| format!("{e:#}"))?,
calibration: mem.calibration().map_err(|e| format!("{e:#}"))?,
root: mem.root().join("memory"),
})
}
pub struct App {
pub theme: Theme,
pub root: PathBuf,
pub path: String,
pub runtime: &'static str,
pub config: DecisionConfig,
pub observers: Vec<(String, String)>,
pub evaluators: Vec<(String, String)>,
pub tab: Tab,
pub focus: Focus,
pub mode: Mode,
pub should_quit: bool,
pub tick: u64,
pub status: Status,
pub busy: Option<&'static str>,
pub help: bool,
pub world: Option<WorldState>,
pub object_sel: usize,
pub signal_sel: usize,
pub grounded: BTreeSet<String>,
pub goal: String,
pub constraint_draft: String,
pub must_not: Vec<String>,
pub cycle: Option<Cycle>,
pub cycle_persisted: bool,
pub matrix_sel: usize,
pub records: Vec<RecordRow>,
pub record_sel: usize,
pub open_record: Option<Box<(DecisionRecord, Verification)>>,
pub memory: Option<MemoryView>,
}
impl App {
pub fn new(theme: Theme, root: PathBuf, path: String, agent: &Agent) -> App {
App {
theme,
root,
path,
runtime: scema_agent::RUNTIME,
config: agent.config,
observers: agent
.observers()
.iter()
.map(|o| (o.name().to_string(), o.about().to_string()))
.collect(),
evaluators: agent
.evaluators()
.iter()
.map(|e| (e.name().to_string(), e.about().to_string()))
.collect(),
tab: Tab::World,
focus: Focus::Left,
mode: Mode::Normal,
should_quit: false,
tick: 0,
status: Status::Idle,
busy: None,
help: false,
world: None,
object_sel: 0,
signal_sel: 0,
grounded: BTreeSet::new(),
goal: String::new(),
constraint_draft: String::new(),
must_not: Vec::new(),
cycle: None,
cycle_persisted: false,
matrix_sel: 0,
records: Vec::new(),
record_sel: 0,
open_record: None,
memory: None,
}
}
pub fn build_goal(&self) -> Goal {
let mut g = Goal::new("goal", self.goal.trim());
for spec in &self.must_not {
let (subject, detail) = match spec.split_once(':') {
Some((a, b)) => (a.trim(), b.trim()),
None => (spec.trim(), "declared in the console"),
};
if !subject.is_empty() {
g = g.with_constraint(Constraint::must_not(subject, detail));
}
}
for id in &self.grounded {
g = g.grounded(id);
}
g
}
pub fn dangling_grounds(&self) -> Vec<String> {
let Some(w) = &self.world else {
return self.grounded.iter().cloned().collect();
};
self.grounded
.iter()
.filter(|id| !w.signals.iter().any(|s| &&s.id == id))
.cloned()
.collect()
}
pub fn cycle_blocked(&self) -> Option<&'static str> {
if self.world.is_none() {
return Some("nothing has been observed yet — press `o` on the WORLD tab");
}
if self.goal.trim().is_empty() {
return Some("no goal — press `g` and type one");
}
if self.busy.is_some() {
return Some("already working");
}
None
}
pub fn absorb(&mut self, done: Done) {
self.busy = None;
match done {
Done::Observed(Ok(w)) => {
let signals = w.signals.len();
let blind = w.blind_spots.len();
self.world = Some(*w);
self.object_sel = 0;
self.signal_sel = 0;
self.status = Status::Note(format!(
"observed: {signals} counted signal(s), {blind} blind spot(s)"
));
}
Done::Observed(Err(e)) => {
self.world = None;
self.status = Status::Error(format!("observe failed: {e}"));
}
Done::Cycled(Ok(c)) => {
self.cycle_persisted = c.record_path.is_some();
self.status = if self.cycle_persisted {
Status::Note(format!(
"sealed {} · {} memory record(s) appended",
c.record.id, c.remembered
))
} else {
Status::Note(format!(
"simulated — nothing written. Would seal as {}.",
c.record.id
))
};
self.cycle = Some(*c);
self.matrix_sel = 0;
self.tab = Tab::Simulate;
}
Done::Cycled(Err(e)) => self.status = Status::Error(format!("cycle failed: {e}")),
Done::Records(Ok(rows)) => {
let bad = rows.iter().filter(|r| r.valid == Some(false)).count();
let unreadable = rows.iter().filter(|r| r.unreadable.is_some()).count();
self.record_sel = self.record_sel.min(rows.len().saturating_sub(1));
self.records = rows;
self.status = match (bad, unreadable) {
(0, 0) => Status::Note(format!("{} record(s), all verify", self.records.len())),
(b, 0) => Status::Error(format!("{b} record(s) DO NOT VERIFY")),
(0, u) => Status::Warn(format!("{u} record(s) could not be read")),
(b, u) => Status::Error(format!("{b} invalid, {u} unreadable")),
};
}
Done::Records(Err(e)) => self.status = Status::Error(format!("record store: {e}")),
Done::Opened(Ok(pair)) => {
self.status = if pair.1.valid {
Status::Note(format!("{} verifies", pair.0.id))
} else {
Status::Error(format!(
"{} DOES NOT VERIFY — {} field(s) moved",
pair.0.id,
pair.1.mismatches.len()
))
};
self.open_record = Some(pair);
}
Done::Opened(Err(e)) => self.status = Status::Error(format!("record: {e}")),
Done::Memory(Ok(m)) => self.memory = Some(*m),
Done::Memory(Err(e)) => self.status = Status::Error(format!("memory: {e}")),
}
}
pub fn step(index: &mut usize, len: usize, delta: isize) {
if len == 0 {
*index = 0;
return;
}
let next = (*index as isize + delta).clamp(0, len as isize - 1);
*index = next as usize;
}
pub fn toggle_ground(&mut self) {
let Some(w) = &self.world else { return };
let Some(s) = w.signals.get(self.signal_sel) else { return };
if !self.grounded.remove(&s.id) {
self.grounded.insert(s.id.clone());
}
self.status = Status::Note(format!(
"{} ground(s) asserted — an instruction is not evidence, a ticked signal is",
self.grounded.len()
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use scema_world::{Domain, Entity, EntityKind, Extent, Polarity, Signal};
fn world(signal_ids: &[&str]) -> WorldState {
WorldState {
schema: Some(scema_world::WORLD_SCHEMA.into()),
observer: "test".into(),
entity: Entity {
kind: EntityKind::Repository,
locator: "/r".into(),
label: "r".into(),
},
domain: Domain::Software,
observed_at: 0,
objects: vec![],
facts: vec![],
signals: signal_ids
.iter()
.map(|id| Signal {
id: (*id).into(),
polarity: Polarity::Risk,
label: (*id).into(),
detail: String::new(),
magnitude: 0.5,
measured: true,
targets: vec![],
evidence: vec![],
})
.collect(),
extent: Extent::complete(0, "t"),
blind_spots: vec![],
}
}
fn app() -> App {
let dir = std::env::temp_dir().join("scema-tui-test");
let agent = Agent::new(&dir, None);
App::new(Theme::default(), dir, ".".into(), &agent)
}
#[test]
fn grounding_comes_only_from_the_ticked_set() {
let mut a = app();
a.world = Some(world(&["untested:scema-cli"]));
a.goal = "add tests to untested:scema-cli".into();
assert!(a.build_goal().grounded_in.is_empty());
a.signal_sel = 0;
a.toggle_ground();
assert_eq!(a.build_goal().grounded_in, vec!["untested:scema-cli".to_string()]);
}
#[test]
fn a_ground_left_over_from_an_earlier_world_is_reported_not_dropped() {
let mut a = app();
a.world = Some(world(&["a"]));
a.grounded.insert("a".into());
a.grounded.insert("gone".into());
assert_eq!(a.dangling_grounds(), vec!["gone".to_string()]);
}
#[test]
fn an_empty_constraint_subject_is_dropped_rather_than_forbidding_everything() {
let mut a = app();
a.must_not.push(" ".into());
a.must_not.push("crates/x:frozen".into());
let g = a.build_goal();
assert_eq!(g.constraints.len(), 1);
assert_eq!(g.constraints[0].subject, "crates/x");
}
#[test]
fn a_cycle_is_blocked_with_a_reason_rather_than_silently_doing_nothing() {
let mut a = app();
assert!(a.cycle_blocked().unwrap().contains("observed"));
a.world = Some(world(&["a"]));
assert!(a.cycle_blocked().unwrap().contains("goal"));
a.goal = "do the thing".into();
assert!(a.cycle_blocked().is_none());
a.busy = Some("observing");
assert!(a.cycle_blocked().is_some());
}
#[test]
fn selection_clamps_and_does_not_wrap() {
let mut i = 0usize;
App::step(&mut i, 3, -1);
assert_eq!(i, 0);
App::step(&mut i, 3, 5);
assert_eq!(i, 2);
App::step(&mut i, 0, 1);
assert_eq!(i, 0);
}
#[test]
fn an_unreadable_record_is_a_third_state_not_an_invalid_one() {
let row = RecordRow {
id: "x".into(),
at: 0,
goal: String::new(),
entity: String::new(),
chosen: None,
outcome: String::new(),
valid: None,
unreadable: Some("bad json".into()),
};
assert!(row.valid.is_none());
assert_ne!(row.valid, Some(false));
}
#[test]
fn tabs_cycle_in_both_directions() {
assert_eq!(Tab::World.next(), Tab::Simulate);
assert_eq!(Tab::World.prev(), Tab::Policy);
assert_eq!(Tab::Policy.next(), Tab::World);
}
#[test]
fn every_tab_states_the_question_it_answers() {
let mut qs: Vec<&str> = Tab::ALL.iter().map(|t| t.question()).collect();
qs.sort_unstable();
let before = qs.len();
qs.dedup();
assert_eq!(before, qs.len(), "two tabs claim to answer the same question");
}
}