Improve behavior authoring and typed trace history (untested checkpoint)
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
Codex
2026-09-10 11:07:07 -07:00
parent 1797516f46
commit 01bb52a76d
25 changed files with 4078 additions and 171 deletions
@@ -0,0 +1,666 @@
//! Typed editing of stored observations, independently of the live counterparts.
//! The checked-in, contract-tested OpenAPI descriptor supplies only field shapes.
//! Serde and core historical validation remain the admission authority.
use super::*;
use iced::widget::column;
use std::sync::OnceLock;
pub(super) const TOGGLE: &str = "/$stored_history";
pub(super) const PAGE: &str = "/$stored_history_page";
pub(super) const VARIANT: &str = "/$stored_history_variant";
const ROOT: &str = "/baseline";
const FACETS: &str = "/baseline/semantic/facets";
const PAGE_SIZE: usize = 12;
const MAX_DEPTH: usize = 32;
#[derive(Default)]
pub(super) struct State {
pub open: bool,
pub pages: BTreeMap<String, usize>,
}
pub(super) fn is_path(path: &str) -> bool {
path == ROOT || path.starts_with("/baseline/")
}
fn api() -> &'static Value {
static API: OnceLock<Value> = OnceLock::new();
API.get_or_init(|| {
serde_json::from_str(include_str!("../../../../docs/openapi.json"))
.expect("The bundled OpenAPI descriptor is valid JSON")
})
}
fn resolve(mut schema: &'static Value) -> Result<&'static Value, &'static str> {
for _ in 0..MAX_DEPTH {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return Ok(schema);
};
schema = reference
.strip_prefix('#')
.and_then(|path| api().pointer(path))
.ok_or("Stored-history descriptor has an unresolved local reference.")?;
}
Err("Stored-history descriptor reference depth exceeded.")
}
fn named(name: &str) -> Result<&'static Value, &'static str> {
api()["components"]["schemas"]
.get(name)
.ok_or("Stored-history descriptor is missing a required type.")
}
fn alternatives(schema: &'static Value) -> Option<&'static Vec<Value>> {
schema
.get("oneOf")
.or_else(|| schema.get("anyOf"))
.and_then(Value::as_array)
}
fn at_schema(schema: &'static Value, parts: &[&str], depth: usize) -> Option<&'static Value> {
if depth > MAX_DEPTH {
return None;
}
let schema = resolve(schema).ok()?;
let Some((part, rest)) = parts.split_first() else {
return Some(schema);
};
if let Some(options) = alternatives(schema) {
return options.iter().find_map(|s| at_schema(s, parts, depth + 1));
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => at_schema(schema.get("properties")?.get(*part)?, rest, depth + 1),
Some("array") if part.parse::<usize>().is_ok() => {
at_schema(schema.get("items")?, rest, depth + 1)
}
_ => None,
}
}
fn schema_at(path: &str) -> Option<&'static Value> {
let tail = path.strip_prefix(ROOT)?;
let parts: Vec<_> = tail
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
at_schema(named("TraceBaseline").ok()?, &parts, 0)
}
pub(super) fn has_field(path: &str) -> bool {
schema_at(path).is_some()
}
pub(super) fn free_string(path: &str) -> bool {
fn text_shape(schema: &'static Value, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let Ok(schema) = resolve(schema) else {
return false;
};
if let Some(options) = alternatives(schema) {
return options.iter().any(|s| text_shape(s, depth + 1));
}
schema.get("type").and_then(Value::as_str) == Some("string")
&& schema.get("enum").is_none()
&& schema.get("format").and_then(Value::as_str) != Some("uuid")
}
schema_at(path).is_some_and(|s| text_shape(s, 0))
}
/// A shape seed is a draft, never an acknowledged historical fact. UUIDs start
/// empty. Observed starts NA on both sides, which core rejects. Typed defaults
/// inside Value are introduced only after the engineer chooses that branch.
fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
if depth > MAX_DEPTH {
return Err("Stored-history constructor depth exceeded.");
}
let schema = resolve(schema)?;
if let Some(options) = alternatives(schema) {
if options
.iter()
.any(|s| matches_value(s, &json!("NotApplicable"), 0))
{
return Ok(json!("NotApplicable"));
}
if options.iter().any(|s| matches_value(s, &Value::Null, 0)) {
return Ok(Value::Null);
}
return seed(
options
.first()
.ok_or("Stored-history union has no constructors.")?,
depth + 1,
);
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return values
.first()
.cloned()
.ok_or("Stored-history enumeration has no values.");
}
Ok(match schema.get("type").and_then(Value::as_str) {
Some("object") => {
let fields = schema
.get("properties")
.and_then(Value::as_object)
.ok_or("Stored-history object has no closed field descriptor.")?;
let mut result = serde_json::Map::new();
for (name, schema) in fields {
result.insert(name.clone(), seed(schema, depth + 1)?);
}
Value::Object(result)
}
Some("array") => json!([]),
Some("boolean") => json!(false),
Some("integer") => json!(0),
Some("number") => json!(0.0),
Some("string") => json!(""),
Some("null") => Value::Null,
_ => return Err("Unsupported stored-history field descriptor."),
})
}
/// Shape matching selects the displayed constructor; it is not validation.
/// Required values may be incomplete while their chosen branch stays editable.
fn matches_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let Ok(schema) = resolve(schema) else {
return false;
};
if let Some(options) = alternatives(schema) {
return options.iter().any(|s| matches_value(s, value, depth + 1));
}
if let Some(choices) = schema.get("enum").and_then(Value::as_array) {
return choices.contains(value);
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => value.as_object().is_some_and(|fields| {
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
fields.keys().all(|key| properties.contains_key(key))
&& schema
.get("required")
.and_then(Value::as_array)
.is_none_or(|required| {
required
.iter()
.filter_map(Value::as_str)
.all(|key| fields.contains_key(key))
})
})
}),
Some("array") => value.is_array(),
Some("boolean") => value.is_boolean(),
Some("integer" | "number") => value.is_number(),
Some("string") => value.is_string(),
Some("null") => value.is_null(),
_ => false,
}
}
fn label(name: &str) -> String {
match name {
"oa" => return "Operational observation".into(),
"sa" => return "System observation".into(),
"NotApplicable" => return "Not applicable".into(),
"Value" => return "Observed value".into(),
"keys" => return "Exact historical keys".into(),
"review" => return "Stored observations".into(),
_ => {}
}
let mut out = String::new();
let mut previous_lower = false;
for c in name.chars() {
if c == '_' {
out.push(' ');
} else {
if c.is_uppercase() && previous_lower {
out.push(' ');
}
out.push(c);
}
previous_lower = c.is_lowercase();
}
let mut chars = out.chars();
chars
.next()
.map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
}
fn branch_label(schema: &'static Value) -> String {
let Ok(schema) = resolve(schema) else {
return "Unavailable type".into();
};
if let Some(word) = schema
.get("enum")
.and_then(Value::as_array)
.filter(|v| v.len() == 1)
.and_then(|v| v[0].as_str())
{
return label(word);
}
if schema.get("enum").is_some() {
return "Enumeration · choose below".into();
}
if let Some(fields) = schema.get("properties").and_then(Value::as_object)
&& fields.len() == 1
{
return label(fields.keys().next().unwrap());
}
match schema.get("type").and_then(Value::as_str) {
Some("null") => "Absent (none)".into(),
Some("object") => "Set record".into(),
Some("array") => "Set list".into(),
Some("string") if schema.get("format").and_then(Value::as_str) == Some("uuid") => {
"Set historical UUID".into()
}
Some("string") => "Set text".into(),
Some("integer" | "number") => "Set number".into(),
_ => "Set value / choose constructor".into(),
}
}
#[derive(Clone, PartialEq, Eq)]
struct FactChoice {
label: String,
value: Value,
}
impl std::fmt::Display for FactChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
/// Flatten only the closed FacetReview selector levels, not observation values.
/// Collection and incident-end choices are selectors too, so none is guessed.
fn fact_choices() -> Result<&'static Vec<FactChoice>, &'static str> {
static CHOICES: OnceLock<Result<Vec<FactChoice>, &'static str>> = OnceLock::new();
CHOICES
.get_or_init(|| {
let root = resolve(named("FacetReview")?)?;
let mut result = vec![];
for branch in alternatives(root).ok_or("FacetReview must be a closed union.")? {
let outer = resolve(branch)?;
let (family, payload) = outer["properties"]
.as_object()
.and_then(|v| v.iter().next())
.ok_or("Facet family is missing.")?;
let payload = resolve(payload)?;
if let Some(leaves) = alternatives(payload) {
for leaf in leaves {
let mut value = serde_json::Map::new();
value.insert(family.clone(), seed(leaf, 0)?);
result.push(FactChoice {
label: format!("{} / {}", label(family), branch_label(leaf)),
value: Value::Object(value),
});
}
} else if family == "Artifact" {
let leaves = alternatives(resolve(&payload["properties"]["review"])?)
.ok_or("Artifact facts must be a closed union.")?;
for leaf in leaves {
let mut value = seed(branch, 0)?;
value[family]["review"] = seed(leaf, 0)?;
result.push(FactChoice {
label: format!("Artifact / {}", branch_label(leaf)),
value,
});
}
} else if matches!(family.as_str(), "Member" | "Order" | "Incident") {
let field = if family == "Incident" {
"end"
} else {
"collection"
};
let choices = resolve(&payload["properties"][field])?["enum"]
.as_array()
.ok_or("Historical selector choices are missing.")?;
for choice in choices {
let mut value = seed(branch, 0)?;
value[family][field] = choice.clone();
result.push(FactChoice {
label: format!(
"{} / {}",
label(family),
label(choice.as_str().unwrap_or(""))
),
value,
});
}
} else {
result.push(FactChoice {
label: label(family),
value: seed(branch, 0)?,
});
}
}
Ok(result)
})
.as_ref()
.map_err(|error| *error)
}
fn variant_message(path: &str, value: Value, present: bool) -> Message {
Message::FieldShape(
if present {
format!("{VARIANT}{path}")
} else {
path.into()
},
value,
)
}
fn branch_seed(schema: &'static Value) -> Result<Value, &'static str> {
let schema = resolve(schema)?;
if let Some(values) = schema
.get("enum")
.and_then(Value::as_array)
.filter(|v| v.len() == 1)
{
Ok(values[0].clone())
} else {
seed(schema, 0)
}
}
fn branch_has_fields(schema: &'static Value) -> bool {
let Ok(schema) = resolve(schema) else {
return false;
};
schema.get("type").and_then(Value::as_str) != Some("null")
&& schema
.get("enum")
.and_then(Value::as_array)
.is_none_or(|values| values.len() > 1)
}
fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) {
let last = count.saturating_sub(1) / PAGE_SIZE;
let page = draft
.history
.pages
.get(path)
.copied()
.unwrap_or(0)
.min(last);
let event = |page| Message::FieldShape(format!("{PAGE}{path}"), json!(page));
let view = row![
button("Previous").on_press_maybe((page > 0).then(|| event(page - 1))),
text(format!(
"{}–{} of {count}",
if count == 0 { 0 } else { page * PAGE_SIZE + 1 },
((page + 1) * PAGE_SIZE).min(count)
))
.size(11),
button("Next").on_press_maybe((page < last).then(|| event(page + 1)))
]
.spacing(6)
.align_y(iced::Center)
.into();
(page, view)
}
fn render<'a>(
draft: &'a Draft,
project: &'a Project,
schema: &'static Value,
value: Option<&'a Value>,
path: &str,
title: &str,
depth: usize,
) -> View<'a, Message> {
if depth > MAX_DEPTH {
return text("Stored-history field nesting exceeds the supported editor depth.")
.size(11)
.into();
}
let schema = match resolve(schema) {
Ok(s) => s,
Err(e) => return text(e).size(11).into(),
};
let present = value.is_some();
let value = value.unwrap_or(&Value::Null);
if let Some(options) = alternatives(schema) {
let initial: Vec<_> = match options.iter().map(branch_seed).collect::<Result<_, _>>() {
Ok(values) => values,
Err(error) => {
return text(format!(
"Stored-history constructor is unavailable: {error}"
))
.size(11)
.into();
}
};
let labels: Vec<_> = options.iter().map(branch_label).collect();
let selected = options.iter().position(|s| matches_value(s, value, 0));
let path_owned = path.to_string();
let labels_owned = labels.clone();
let mut out = column![
text(title.to_owned()).size(11).color(style::MUTED),
pick_list(
labels,
selected.map(|i| branch_label(&options[i])),
move |choice| {
let index = labels_owned.iter().position(|v| v == &choice).unwrap();
variant_message(&path_owned, initial[index].clone(), present)
}
)
.placeholder("Choose stored value / constructor…")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(6);
if let Some(index) = selected {
let branch = resolve(&options[index]).unwrap_or(&options[index]);
if branch_has_fields(branch) {
out = out.push(render(
draft,
project,
branch,
Some(value),
path,
"",
depth + 1,
));
}
}
return out.into();
}
if let Some(words) = schema.get("enum").and_then(Value::as_array) {
let choices: Vec<_> = words
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
let selected = value
.as_str()
.map(str::to_owned)
.filter(|v| choices.contains(v));
let path = path.to_string();
return column![
text(title.to_owned()).size(11).color(style::MUTED),
pick_list(choices, selected, move |word| Message::FieldShape(
path.clone(),
json!(word)
))
.placeholder("Choose an explicit value…")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(6)
.into();
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => {
let Some(fields) = schema.get("properties").and_then(Value::as_object) else {
return text("Unsupported stored-history object shape.").size(11).into();
};
let mut out = column![].spacing(8);
if !title.is_empty() { out = out.push(text(title.to_owned()).size(12).color(style::INK)); }
for (key, child_schema) in fields {
out = out.push(render(draft, project, child_schema, value.get(key), &format!("{path}/{key}"), &label(key), depth + 1));
}
out.into()
}
Some("array") => {
let Some(items) = value.as_array() else {
return button("Initialize empty list").on_press(Message::FieldShape(path.into(), json!([]))).into();
};
let Some(item_schema) = schema.get("items") else { return text("Missing history list item type.").into(); };
let mut out = column![text(title.to_owned()).size(12)].spacing(8);
if path == FACETS {
match fact_choices() {
Ok(choices) => {
let path = path.to_owned();
out = out.push(pick_list(choices.clone(), None::<FactChoice>, move |choice| Message::FieldAppend(path.clone(), choice.value))
.placeholder("Add: choose family and fact…").text_size(12).padding(8).width(Fill));
}
Err(error) => { out = out.push(text(error).size(11)); }
}
} else {
match seed(item_schema, 0) {
Ok(initial) => out = out.push(button("Add list value").on_press(Message::FieldAppend(path.into(), initial))),
Err(error) => out = out.push(text(error).size(11)),
}
}
let (page, pager) = paging(draft, path, items.len());
if items.len() > PAGE_SIZE { out = out.push(pager); }
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let item_path = format!("{path}/{index}");
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
if path == FACETS {
if let Some(current) = current_observation(draft, project, &item_path) {
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
}
card = card.push(row![
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))),
button("Remove").on_press(Message::FieldArray(path.into(), Some(index), 0))
].spacing(6));
out = out.push(container(card).padding(10).style(style::subtle));
}
if items.is_empty() { out = out.push(text("Empty list · no stored entries").size(11).color(style::MUTED)); }
out.into()
}
Some("boolean") => {
let path = path.to_owned();
checkbox(value.as_bool().unwrap_or(false)).label(title.to_owned())
.on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))).text_size(12).into()
}
Some("integer" | "number" | "string") => {
let is_uuid = schema.get("format").and_then(Value::as_str) == Some("uuid");
let numeric = schema.get("type").and_then(Value::as_str) != Some("string");
let mut out = column![text(title.to_owned()).size(11).color(style::MUTED)].spacing(5);
if let Some(input) = draft.inputs.get(path) {
let owned = path.to_owned();
if !numeric && !is_uuid && !draft.exact_strings {
out = out.push(text_editor(&input.editor).on_action(move |a| Message::FieldText(owned.clone(), a)).size(12).height(72).padding(8));
} else {
out = out.push(text_input(if is_uuid { "Historical UUID, including deleted identities" } else { "Stored value" }, &input.raw)
.on_input(move |raw| Message::Field(owned.clone(), raw)).size(12).padding(8));
}
} else {
match seed(schema, 0) {
Ok(value) => out = out.push(button("Set stored value").on_press(Message::FieldShape(path.into(), value))),
Err(error) => out = out.push(text(error).size(11)),
}
}
if is_uuid { out = out.push(text("Historical identity · existence in the current model is not required.").size(10).color(style::MUTED)); }
out.into()
}
Some("null") => text("Absent (none)").size(11).into(),
_ => text("Unsupported stored-history descriptor. The draft is retained; this field cannot be edited with these controls.").size(11).into(),
}
}
fn current_observation(draft: &Draft, project: &Project, item_path: &str) -> Option<FacetReview> {
let raw = draft
.materialize_scopes(&[item_path.to_owned(), "/oa".into(), "/sa".into()])
.ok()?;
let fact: FacetReview = serde_json::from_value(raw.pointer(item_path)?.clone()).ok()?;
let oa = raw.get("oa")?.as_str()?.parse().ok()?;
let sa = raw.get("sa")?.as_str()?.parse().ok()?;
semantic::capture_facet(project, oa, sa, &fact.selector()).ok()
}
pub(super) fn view<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
) -> View<'a, Message> {
let count = value
.pointer("/semantic/facets")
.and_then(Value::as_array)
.map_or(0, Vec::len);
let mut out = column![
text("Stored review history").size(12).color(style::INK),
text(if value.is_null() {
"No stored baseline".into()
} else {
format!("Prose baseline · {count} semantic observations")
})
.size(11)
.color(style::MUTED),
button(if draft.history.open {
"Close stored-history editor"
} else {
"Edit stored history (advanced)…"
})
.on_press(Message::FieldShape(
TOGGLE.into(),
json!(!draft.history.open)
))
.style(style::nav)
]
.spacing(8);
if !draft.history.open {
return out.into();
}
out = out.push(text("These edits change the trace's stored observations only. They do not change either counterpart. Ordinary Keep/Take review remains available from Traceability.").size(11));
if value.is_null() {
return match named("TraceBaseline").and_then(|s| seed(s, 0)) {
Ok(initial) => out
.push(
button("Start empty stored baseline")
.on_press(Message::FieldShape(ROOT.into(), initial)),
)
.into(),
Err(error) => out.push(text(error).size(11)).into(),
};
}
out = out.push(
button("Clear whole stored baseline")
.on_press(Message::FieldShape(ROOT.into(), Value::Null))
.style(style::nav),
);
match named("TraceBaseline") {
Ok(schema) => out = out.push(render(draft, project, schema, Some(value), ROOT, "", 0)),
Err(error) => out = out.push(text(error).size(11)),
}
let checked = draft.materialize_scopes(&[ROOT.into()]).and_then(|raw| {
let baseline: TraceBaseline =
serde_json::from_value(raw["baseline"].clone()).map_err(|e| e.to_string())?;
baseline.semantic.as_ref().map_or(Ok(()), |b| {
semantic::validate_semantic_baseline(b).map_err(|e| e.join("; "))
})
});
if let Err(error) = checked {
out = out.push(text(format!("Stored history is incomplete or invalid: {error}")).size(11));
}
out.into()
}
#[cfg(test)]
mod tests;