914 lines
32 KiB
Rust
914 lines
32 KiB
Rust
//! Isolated context experiment. This is deliberately NOT Studio syntax or production code.
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fmt::Write as _;
|
|
use std::path::Path;
|
|
|
|
type Bindings = BTreeMap<String, Expr>;
|
|
type Functions = BTreeMap<String, Function>;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum Expr {
|
|
Int(i64),
|
|
Missing,
|
|
Ref(String),
|
|
Sum(Vec<Expr>),
|
|
Le(Box<Expr>, Box<Expr>),
|
|
Call(String, Vec<Expr>),
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Function {
|
|
parameters: Vec<String>,
|
|
body: Expr,
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Baseline {
|
|
name: String,
|
|
revision: u64,
|
|
bindings: Bindings,
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Case {
|
|
name: String,
|
|
base: Option<String>,
|
|
bindings: Bindings,
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Study {
|
|
mode: char,
|
|
revision: u64,
|
|
properties: Vec<String>,
|
|
functions: Functions,
|
|
baseline: Option<Baseline>,
|
|
cases: Vec<Case>,
|
|
origin: Option<String>,
|
|
captured_origins: BTreeMap<String, String>,
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum Value {
|
|
Int(i64),
|
|
Bool(bool),
|
|
Missing(BTreeSet<String>),
|
|
}
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Observation {
|
|
value: Value,
|
|
origins: BTreeMap<String, String>,
|
|
document_revision: u64,
|
|
}
|
|
|
|
fn split_args(s: &str) -> Result<Vec<&str>, String> {
|
|
if s.trim().is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
let mut depth = 0_i32;
|
|
let mut start = 0;
|
|
let mut out = vec![];
|
|
for (i, ch) in s.char_indices() {
|
|
match ch {
|
|
'(' => depth += 1,
|
|
')' => {
|
|
depth -= 1;
|
|
if depth < 0 {
|
|
return Err("unbalanced expression".into());
|
|
}
|
|
}
|
|
',' if depth == 0 => {
|
|
out.push(s[start..i].trim());
|
|
start = i + 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if depth != 0 {
|
|
return Err("unbalanced expression".into());
|
|
}
|
|
out.push(s[start..].trim());
|
|
Ok(out)
|
|
}
|
|
fn expr(s: &str) -> Result<Expr, String> {
|
|
let s = s.trim();
|
|
if s == "missing" {
|
|
return Ok(Expr::Missing);
|
|
}
|
|
if let Ok(n) = s.parse() {
|
|
return Ok(Expr::Int(n));
|
|
}
|
|
if let Some(at) = s.find('(') {
|
|
if !s.ends_with(')') {
|
|
return Err("missing closing parenthesis".into());
|
|
}
|
|
let args = split_args(&s[at + 1..s.len() - 1])?
|
|
.into_iter()
|
|
.map(expr)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
return match &s[..at] {
|
|
"sum" => Ok(Expr::Sum(args)),
|
|
"le" if args.len() == 2 => Ok(Expr::Le(
|
|
Box::new(args[0].clone()),
|
|
Box::new(args[1].clone()),
|
|
)),
|
|
"le" => Err("le requires two arguments".into()),
|
|
name => Ok(Expr::Call(name.into(), args)),
|
|
};
|
|
}
|
|
if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
return Err(format!("invalid symbol {s}"));
|
|
}
|
|
Ok(Expr::Ref(s.into()))
|
|
}
|
|
fn spelling(e: &Expr) -> String {
|
|
let list = |xs: &[Expr]| xs.iter().map(spelling).collect::<Vec<_>>().join(", ");
|
|
match e {
|
|
Expr::Int(v) => v.to_string(),
|
|
Expr::Missing => "missing".into(),
|
|
Expr::Ref(s) => s.clone(),
|
|
Expr::Sum(xs) => format!("sum({})", list(xs)),
|
|
Expr::Le(a, b) => format!("le({}, {})", spelling(a), spelling(b)),
|
|
Expr::Call(name, xs) => format!("{name}({})", list(xs)),
|
|
}
|
|
}
|
|
fn parse(text: &str) -> Result<Study, String> {
|
|
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
|
|
let head = lines
|
|
.next()
|
|
.ok_or("empty study")?
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>();
|
|
if head.len() != 4
|
|
|| head[0] != "study"
|
|
|| head[2] != "revision"
|
|
|| !matches!(head[1], "B" | "D")
|
|
{
|
|
return Err("invalid study header".into());
|
|
}
|
|
let mut study = Study {
|
|
mode: head[1].chars().next().unwrap(),
|
|
revision: head[3].parse().map_err(|_| "bad revision")?,
|
|
properties: vec![],
|
|
functions: BTreeMap::new(),
|
|
baseline: None,
|
|
cases: vec![],
|
|
origin: None,
|
|
captured_origins: BTreeMap::new(),
|
|
};
|
|
let mut current: Option<(bool, Case, u64)> = None;
|
|
for line in lines {
|
|
let line = line.trim();
|
|
if line == "end" {
|
|
let (base, item, revision) = current.take().ok_or("unmatched end")?;
|
|
if base {
|
|
if study.baseline.is_some() {
|
|
return Err("multiple baselines forbidden".into());
|
|
}
|
|
study.baseline = Some(Baseline {
|
|
name: item.name,
|
|
revision,
|
|
bindings: item.bindings,
|
|
});
|
|
} else {
|
|
if study.cases.iter().any(|c| c.name == item.name) {
|
|
return Err("duplicate case".into());
|
|
}
|
|
study.cases.push(item);
|
|
}
|
|
} else if let Some((_, case, _)) = current.as_mut() {
|
|
let (name, value) = line.split_once(" = ").ok_or("expected binding")?;
|
|
if case.bindings.insert(name.into(), expr(value)?).is_some() {
|
|
return Err("duplicate binding".into());
|
|
}
|
|
} else if let Some(properties) = line.strip_prefix("properties ") {
|
|
if !study.properties.is_empty() {
|
|
return Err("duplicate properties".into());
|
|
}
|
|
study.properties = properties.split(", ").map(String::from).collect();
|
|
} else if let Some(origin) = line.strip_prefix("origin ") {
|
|
study.origin = Some(origin.into());
|
|
} else if let Some(captured) = line.strip_prefix("captured ") {
|
|
let (property, origin) = captured
|
|
.split_once(" = ")
|
|
.ok_or("invalid captured origin")?;
|
|
if study
|
|
.captured_origins
|
|
.insert(property.into(), origin.into())
|
|
.is_some()
|
|
{
|
|
return Err("duplicate captured origin".into());
|
|
}
|
|
} else if let Some(def) = line.strip_prefix("function ") {
|
|
let (left, right) = def.split_once(" = ").ok_or("invalid function")?;
|
|
let at = left.find('(').ok_or("function parameters required")?;
|
|
if !left.ends_with(')') {
|
|
return Err("bad parameters".into());
|
|
}
|
|
let f = Function {
|
|
parameters: split_args(&left[at + 1..left.len() - 1])?
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
body: expr(right)?,
|
|
};
|
|
if study.functions.insert(left[..at].into(), f).is_some() {
|
|
return Err("duplicate function".into());
|
|
}
|
|
} else {
|
|
let words = line.split_whitespace().collect::<Vec<_>>();
|
|
let (base, name, parent, revision) = match words.as_slice() {
|
|
["baseline", name, "revision", revision] => (
|
|
true,
|
|
*name,
|
|
None,
|
|
revision.parse().map_err(|_| "bad baseline revision")?,
|
|
),
|
|
["case", name, "base", parent] => (false, *name, Some((*parent).to_owned()), 0),
|
|
["case", name, "independent"] => (false, *name, None, 0),
|
|
_ => return Err(format!("invalid declaration: {line}")),
|
|
};
|
|
current = Some((
|
|
base,
|
|
Case {
|
|
name: name.into(),
|
|
base: parent,
|
|
bindings: BTreeMap::new(),
|
|
},
|
|
revision,
|
|
));
|
|
}
|
|
}
|
|
if current.is_some() {
|
|
return Err("unterminated block".into());
|
|
}
|
|
validate(&study)?;
|
|
Ok(study)
|
|
}
|
|
fn render(s: &Study) -> String {
|
|
let mut text = format!("study {} revision {}\n", s.mode, s.revision);
|
|
if let Some(origin) = &s.origin {
|
|
writeln!(text, "origin {origin}").unwrap();
|
|
}
|
|
for (property, origin) in &s.captured_origins {
|
|
writeln!(text, "captured {property} = {origin}").unwrap();
|
|
}
|
|
writeln!(text, "properties {}", s.properties.join(", ")).unwrap();
|
|
for (name, f) in &s.functions {
|
|
writeln!(
|
|
text,
|
|
"function {name}({}) = {}",
|
|
f.parameters.join(", "),
|
|
spelling(&f.body)
|
|
)
|
|
.unwrap();
|
|
}
|
|
let write_bindings = |text: &mut String, bindings: &Bindings| {
|
|
for (p, e) in bindings {
|
|
writeln!(text, " {p} = {}", spelling(e)).unwrap();
|
|
}
|
|
text.push_str("end\n");
|
|
};
|
|
if let Some(b) = &s.baseline {
|
|
writeln!(text, "baseline {} revision {}", b.name, b.revision).unwrap();
|
|
write_bindings(&mut text, &b.bindings);
|
|
}
|
|
for c in &s.cases {
|
|
writeln!(
|
|
text,
|
|
"case {} {}",
|
|
c.name,
|
|
c.base
|
|
.as_ref()
|
|
.map_or("independent".into(), |b| format!("base {b}"))
|
|
)
|
|
.unwrap();
|
|
write_bindings(&mut text, &c.bindings);
|
|
}
|
|
text
|
|
}
|
|
fn refs(e: &Expr, names: &mut BTreeSet<String>, calls: &mut BTreeSet<String>) {
|
|
match e {
|
|
Expr::Ref(p) => {
|
|
names.insert(p.clone());
|
|
}
|
|
Expr::Sum(xs) | Expr::Call(_, xs) => {
|
|
if let Expr::Call(f, _) = e {
|
|
calls.insert(f.clone());
|
|
}
|
|
for x in xs {
|
|
refs(x, names, calls);
|
|
}
|
|
}
|
|
Expr::Le(a, b) => {
|
|
refs(a, names, calls);
|
|
refs(b, names, calls);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
fn acyclic(graph: &BTreeMap<String, BTreeSet<String>>) -> Result<(), String> {
|
|
fn visit(
|
|
n: &str,
|
|
graph: &BTreeMap<String, BTreeSet<String>>,
|
|
path: &mut Vec<String>,
|
|
done: &mut BTreeSet<String>,
|
|
) -> Result<(), String> {
|
|
if path.iter().any(|p| p == n) {
|
|
path.push(n.into());
|
|
return Err(format!("cycle: {}", path.join(" -> ")));
|
|
}
|
|
if done.contains(n) {
|
|
return Ok(());
|
|
}
|
|
path.push(n.into());
|
|
for next in graph
|
|
.get(n)
|
|
.ok_or_else(|| format!("unknown dependency {n}"))?
|
|
{
|
|
visit(next, graph, path, done)?;
|
|
}
|
|
path.pop();
|
|
done.insert(n.into());
|
|
Ok(())
|
|
}
|
|
let mut done = BTreeSet::new();
|
|
for n in graph.keys() {
|
|
visit(n, graph, &mut vec![], &mut done)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
fn resolved<'a>(s: &'a Study, c: &'a Case, property: &str) -> Result<(&'a Expr, String), String> {
|
|
if let Some(e) = c.bindings.get(property) {
|
|
return Ok((e, format!("case:{}", c.name)));
|
|
}
|
|
if let Some(base) = &c.base {
|
|
let b = s
|
|
.baseline
|
|
.as_ref()
|
|
.filter(|b| &b.name == base)
|
|
.ok_or("unknown baseline")?;
|
|
if let Some(e) = b.bindings.get(property) {
|
|
return Ok((
|
|
e,
|
|
format!("baseline:{}@{} via {}", b.name, b.revision, c.name),
|
|
));
|
|
}
|
|
}
|
|
Err(format!("missing binding row: {}.{property}", c.name))
|
|
}
|
|
fn validate(s: &Study) -> Result<(), String> {
|
|
let properties = s.properties.iter().cloned().collect::<BTreeSet<_>>();
|
|
if properties.len() != s.properties.len() || properties.is_empty() {
|
|
return Err("invalid property catalog".into());
|
|
}
|
|
if (s.mode == 'B') != s.baseline.is_some() {
|
|
return Err("B requires one explicit baseline; D forbids baselines".into());
|
|
}
|
|
let mut function_graph = BTreeMap::new();
|
|
for (name, f) in &s.functions {
|
|
let mut names = BTreeSet::new();
|
|
let mut calls = BTreeSet::new();
|
|
refs(&f.body, &mut names, &mut calls);
|
|
let parameters = f.parameters.iter().cloned().collect::<BTreeSet<_>>();
|
|
if parameters.len() != f.parameters.len() || !names.is_subset(¶meters) {
|
|
return Err(format!("function {name} captures unknown/project property"));
|
|
}
|
|
function_graph.insert(name.clone(), calls);
|
|
}
|
|
acyclic(&function_graph)?;
|
|
let mut contexts = s.cases.clone();
|
|
if let Some(b) = &s.baseline {
|
|
contexts.push(Case {
|
|
name: format!("baseline_{}", b.name),
|
|
base: None,
|
|
bindings: b.bindings.clone(),
|
|
});
|
|
}
|
|
for c in &contexts {
|
|
if !c.bindings.keys().all(|p| properties.contains(p)) {
|
|
return Err("unknown binding property".into());
|
|
}
|
|
if s.mode == 'D' && c.base.is_some() {
|
|
return Err("D case cannot inherit".into());
|
|
}
|
|
if let Some(name) = &c.base {
|
|
if s.baseline.as_ref().is_none_or(|b| &b.name != name) {
|
|
return Err("unknown baseline; case-to-case inheritance forbidden".into());
|
|
}
|
|
}
|
|
let mut graph = BTreeMap::new();
|
|
for p in &properties {
|
|
let (e, _) = resolved(s, c, p)?;
|
|
let mut names = BTreeSet::new();
|
|
let mut calls = BTreeSet::new();
|
|
refs(e, &mut names, &mut calls);
|
|
if !names.is_subset(&properties) || !calls.iter().all(|f| s.functions.contains_key(f)) {
|
|
return Err("unknown reference".into());
|
|
}
|
|
graph.insert(p.clone(), names);
|
|
}
|
|
acyclic(&graph).map_err(|e| format!("{}: {e}", c.name))?;
|
|
for p in &properties {
|
|
evaluate(s, c, p)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn evaluate(s: &Study, c: &Case, property: &str) -> Result<Observation, String> {
|
|
let mut origins = BTreeMap::new();
|
|
let mut cache = BTreeMap::new();
|
|
fn property_value(
|
|
s: &Study,
|
|
c: &Case,
|
|
p: &str,
|
|
origins: &mut BTreeMap<String, String>,
|
|
cache: &mut BTreeMap<String, Value>,
|
|
) -> Result<Value, String> {
|
|
if let Some(v) = cache.get(p) {
|
|
return Ok(v.clone());
|
|
}
|
|
let (e, origin) = resolved(s, c, p)?;
|
|
origins.insert(
|
|
p.into(),
|
|
s.captured_origins
|
|
.get(p)
|
|
.map_or(origin, |old| format!("frozen:{old}")),
|
|
);
|
|
let value = run(s, c, e, &BTreeMap::new(), Some(p), origins, cache)?;
|
|
if !matches!(value, Value::Missing(_)) && (p == "budget") != matches!(value, Value::Bool(_))
|
|
{
|
|
return Err(format!("wrong type for {p}"));
|
|
}
|
|
cache.insert(p.into(), value.clone());
|
|
Ok(value)
|
|
}
|
|
fn run(
|
|
s: &Study,
|
|
c: &Case,
|
|
e: &Expr,
|
|
params: &BTreeMap<String, Value>,
|
|
missing_property: Option<&str>,
|
|
origins: &mut BTreeMap<String, String>,
|
|
cache: &mut BTreeMap<String, Value>,
|
|
) -> Result<Value, String> {
|
|
match e {
|
|
Expr::Int(n) => Ok(Value::Int(*n)),
|
|
Expr::Missing => Ok(Value::Missing(BTreeSet::from([missing_property
|
|
.unwrap_or("function-body")
|
|
.into()]))),
|
|
Expr::Ref(p) => params
|
|
.get(p)
|
|
.cloned()
|
|
.map_or_else(|| property_value(s, c, p, origins, cache), Ok),
|
|
Expr::Call(name, args) => {
|
|
let f = s.functions.get(name).ok_or("unknown function")?;
|
|
if f.parameters.len() != args.len() {
|
|
return Err("wrong function arity".into());
|
|
}
|
|
let mut bound = BTreeMap::new();
|
|
for (parameter, arg) in f.parameters.iter().zip(args) {
|
|
bound.insert(
|
|
parameter.clone(),
|
|
run(s, c, arg, params, missing_property, origins, cache)?,
|
|
);
|
|
}
|
|
run(s, c, &f.body, &bound, missing_property, origins, cache)
|
|
}
|
|
Expr::Sum(xs) => {
|
|
let mut total = 0_i64;
|
|
let mut missing = BTreeSet::new();
|
|
for x in xs {
|
|
match run(s, c, x, params, missing_property, origins, cache)? {
|
|
Value::Int(n) => total = total.checked_add(n).ok_or("integer overflow")?,
|
|
Value::Missing(m) => missing.extend(m),
|
|
Value::Bool(_) => return Err("sum requires numeric inputs".into()),
|
|
}
|
|
}
|
|
Ok(if missing.is_empty() {
|
|
Value::Int(total)
|
|
} else {
|
|
Value::Missing(missing)
|
|
})
|
|
}
|
|
Expr::Le(a, b) => {
|
|
let a = run(s, c, a, params, missing_property, origins, cache)?;
|
|
let b = run(s, c, b, params, missing_property, origins, cache)?;
|
|
match (a, b) {
|
|
(Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)),
|
|
(Value::Missing(mut a), Value::Missing(b)) => {
|
|
a.extend(b);
|
|
Ok(Value::Missing(a))
|
|
}
|
|
(Value::Missing(a), Value::Int(_)) | (Value::Int(_), Value::Missing(a)) => {
|
|
Ok(Value::Missing(a))
|
|
}
|
|
_ => Err("le requires numeric inputs".into()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let value = property_value(s, c, property, &mut origins, &mut cache)?;
|
|
Ok(Observation {
|
|
value,
|
|
origins,
|
|
document_revision: s.revision,
|
|
})
|
|
}
|
|
fn fixture(mode: char, factored: bool) -> Study {
|
|
let inputs = (1..=20)
|
|
.map(|i| format!("fixed{i:02}"))
|
|
.chain(["varying01".into(), "varying02".into()])
|
|
.collect::<Vec<_>>();
|
|
let mut all = inputs.clone();
|
|
all.extend(["total".into(), "budget".into()]);
|
|
let mut bindings = (1..=20)
|
|
.map(|i| (format!("fixed{i:02}"), Expr::Int(1000)))
|
|
.collect::<Bindings>();
|
|
bindings.insert("varying01".into(), Expr::Int(750));
|
|
bindings.insert("varying02".into(), Expr::Int(1000));
|
|
let total = Expr::Sum(inputs.iter().cloned().map(Expr::Ref).collect());
|
|
let check = Expr::Le(
|
|
Box::new(Expr::Ref("total".into())),
|
|
Box::new(Expr::Int(22000)),
|
|
);
|
|
let mut functions = BTreeMap::new();
|
|
if factored {
|
|
functions.insert(
|
|
"total_formula".into(),
|
|
Function {
|
|
parameters: inputs.clone(),
|
|
body: total,
|
|
},
|
|
);
|
|
functions.insert(
|
|
"budget_formula".into(),
|
|
Function {
|
|
parameters: vec!["result".into()],
|
|
body: Expr::Le(
|
|
Box::new(Expr::Ref("result".into())),
|
|
Box::new(Expr::Int(22000)),
|
|
),
|
|
},
|
|
);
|
|
bindings.insert(
|
|
"total".into(),
|
|
Expr::Call(
|
|
"total_formula".into(),
|
|
inputs.into_iter().map(Expr::Ref).collect(),
|
|
),
|
|
);
|
|
bindings.insert(
|
|
"budget".into(),
|
|
Expr::Call("budget_formula".into(), vec![Expr::Ref("total".into())]),
|
|
);
|
|
} else {
|
|
bindings.insert("total".into(), total);
|
|
bindings.insert("budget".into(), check);
|
|
}
|
|
let cases = (0..20)
|
|
.map(|i| {
|
|
let mut rows = if mode == 'D' {
|
|
bindings.clone()
|
|
} else {
|
|
BTreeMap::new()
|
|
};
|
|
if mode == 'D' || i != 0 {
|
|
rows.insert("varying01".into(), Expr::Int(750 + 50 * i));
|
|
rows.insert("varying02".into(), Expr::Int(1000 + 25 * i));
|
|
}
|
|
Case {
|
|
name: format!("case{:02}", i + 1),
|
|
base: (mode == 'B').then(|| "engineering".into()),
|
|
bindings: rows,
|
|
}
|
|
})
|
|
.collect();
|
|
Study {
|
|
mode,
|
|
revision: 1,
|
|
properties: all,
|
|
functions,
|
|
baseline: (mode == 'B').then(|| Baseline {
|
|
name: "engineering".into(),
|
|
revision: 1,
|
|
bindings,
|
|
}),
|
|
cases,
|
|
origin: None,
|
|
captured_origins: BTreeMap::new(),
|
|
}
|
|
}
|
|
fn freeze(s: &Study, case: usize) -> Study {
|
|
let c = &s.cases[case];
|
|
Study {
|
|
mode: 'D',
|
|
revision: 1,
|
|
properties: s.properties.clone(),
|
|
functions: s.functions.clone(),
|
|
baseline: None,
|
|
cases: vec![Case {
|
|
name: c.name.clone(),
|
|
base: None,
|
|
bindings: s
|
|
.properties
|
|
.iter()
|
|
.map(|p| (p.clone(), resolved(s, c, p).unwrap().0.clone()))
|
|
.collect(),
|
|
}],
|
|
origin: Some(format!(
|
|
"{}:{}:document{}{}",
|
|
s.mode,
|
|
c.name,
|
|
s.revision,
|
|
s.baseline
|
|
.as_ref()
|
|
.map_or(String::new(), |b| format!(":{}@{}", b.name, b.revision))
|
|
)),
|
|
captured_origins: s
|
|
.properties
|
|
.iter()
|
|
.map(|p| (p.clone(), resolved(s, c, p).unwrap().1))
|
|
.collect(),
|
|
}
|
|
}
|
|
fn write_fixture(dir: &Path, name: &str, s: &Study) -> Result<String, String> {
|
|
let source = render(s);
|
|
let back = parse(&source)?;
|
|
assert_eq!(&back, s, "exact experiment AST round trip");
|
|
std::fs::write(dir.join(format!("{name}.study")), &source).map_err(|e| e.to_string())?;
|
|
Ok(source)
|
|
}
|
|
fn diff(before: &str, after: &str) -> (usize, usize) {
|
|
let a = before.lines().collect::<Vec<_>>();
|
|
let b = after.lines().collect::<Vec<_>>();
|
|
let mut row = vec![0; b.len() + 1];
|
|
for x in &a {
|
|
let mut diagonal = 0;
|
|
for (j, y) in b.iter().enumerate() {
|
|
let old = row[j + 1];
|
|
row[j + 1] = if x == y {
|
|
diagonal + 1
|
|
} else {
|
|
row[j + 1].max(row[j])
|
|
};
|
|
diagonal = old;
|
|
}
|
|
}
|
|
(a.len() - row[b.len()], b.len() - row[b.len()])
|
|
}
|
|
fn assert_total(s: &Study, index: usize, expected: i64) {
|
|
assert_eq!(
|
|
evaluate(s, &s.cases[index], "total").unwrap().value,
|
|
Value::Int(expected)
|
|
);
|
|
}
|
|
fn change_fixed(s: &mut Study, all: bool) {
|
|
s.revision += 1;
|
|
if let Some(b) = s.baseline.as_mut() {
|
|
b.revision += 1;
|
|
b.bindings.insert("fixed01".into(), Expr::Int(1200));
|
|
} else {
|
|
for c in s.cases.iter_mut().take(if all { 20 } else { 1 }) {
|
|
c.bindings.insert("fixed01".into(), Expr::Int(1200));
|
|
}
|
|
}
|
|
}
|
|
fn change_threshold(s: &mut Study, value: i64) {
|
|
s.revision += 1;
|
|
if let Some(f) = s.functions.get_mut("budget_formula") {
|
|
f.body = Expr::Le(
|
|
Box::new(Expr::Ref("result".into())),
|
|
Box::new(Expr::Int(value)),
|
|
);
|
|
} else {
|
|
let e = Expr::Le(
|
|
Box::new(Expr::Ref("total".into())),
|
|
Box::new(Expr::Int(value)),
|
|
);
|
|
if let Some(b) = s.baseline.as_mut() {
|
|
b.revision += 1;
|
|
b.bindings.insert("budget".into(), e);
|
|
} else {
|
|
for c in &mut s.cases {
|
|
c.bindings.insert("budget".into(), e.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn pass_count(s: &Study) -> usize {
|
|
s.cases
|
|
.iter()
|
|
.filter(|c| evaluate(s, c, "budget").unwrap().value == Value::Bool(true))
|
|
.count()
|
|
}
|
|
|
|
fn main() -> Result<(), String> {
|
|
let out = std::env::args()
|
|
.nth(1)
|
|
.ok_or("usage: analysis-context.exe OUTPUT_DIRECTORY")?;
|
|
let dir = Path::new(&out);
|
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
let mut report = String::from(
|
|
"# Executed context experiment\n\nAll input magnitudes are integer milliseconds. This tiny dialect is not Studio source.\n\n| Variant | Initial lines | Initial bytes | Binding rows | Fixed-all edit removed/added lines | Threshold edit removed/added lines | Initial passing cases | After fixed-all change |\n| --- | ---: | ---: | ---: | --- | --- | ---: | ---: |\n",
|
|
);
|
|
let mut checks = 0;
|
|
for mode in ['B', 'D'] {
|
|
for factored in [false, true] {
|
|
let name = format!("{mode}-{}", if factored { "factored" } else { "inline" });
|
|
let original = fixture(mode, factored);
|
|
validate(&original)?;
|
|
for i in 0..20 {
|
|
assert_total(&original, i, 21750 + 75 * i as i64);
|
|
checks += 1;
|
|
}
|
|
assert_eq!(pass_count(&original), 4);
|
|
checks += 1;
|
|
let initial = write_fixture(dir, &name, &original)?;
|
|
let mut changed = original.clone();
|
|
change_fixed(&mut changed, true);
|
|
validate(&changed)?;
|
|
for i in 0..20 {
|
|
assert_total(&changed, i, 21950 + 75 * i as i64);
|
|
checks += 1;
|
|
}
|
|
assert_eq!(pass_count(&changed), 1);
|
|
checks += 1;
|
|
let fixed_source = write_fixture(dir, &format!("{name}-fixed-all"), &changed)?;
|
|
let mut threshold = original.clone();
|
|
change_threshold(&mut threshold, 22500);
|
|
validate(&threshold)?;
|
|
assert_eq!(pass_count(&threshold), 11);
|
|
checks += 1;
|
|
let threshold_source = write_fixture(dir, &format!("{name}-threshold"), &threshold)?;
|
|
let frozen = freeze(&original, 19);
|
|
let frozen_before = evaluate(&frozen, &frozen.cases[0], "budget")?;
|
|
assert_total(&frozen, 0, 23175);
|
|
checks += 1;
|
|
change_threshold(&mut changed, 25000);
|
|
assert_eq!(
|
|
evaluate(&frozen, &frozen.cases[0], "budget")?,
|
|
frozen_before
|
|
);
|
|
checks += 1;
|
|
write_fixture(dir, &format!("{name}-frozen-case20"), &frozen)?;
|
|
// A shallow case copy still follows its baseline and/or shared function environment.
|
|
let shallow = original.cases[19].clone();
|
|
if mode == 'B' {
|
|
assert_eq!(
|
|
evaluate(&changed, &shallow, "total")?.value,
|
|
Value::Int(23375)
|
|
);
|
|
checks += 1;
|
|
}
|
|
if factored {
|
|
assert_eq!(
|
|
evaluate(&changed, &shallow, "budget")?.value,
|
|
Value::Bool(true)
|
|
);
|
|
checks += 1;
|
|
}
|
|
if mode == 'D' && !factored {
|
|
assert_eq!(
|
|
evaluate(&changed, &shallow, "total")?.value,
|
|
Value::Int(23175)
|
|
);
|
|
assert_eq!(
|
|
evaluate(&changed, &shallow, "budget")?.value,
|
|
Value::Bool(false)
|
|
);
|
|
checks += 2;
|
|
}
|
|
assert_eq!(frozen.captured_origins.len(), 24);
|
|
assert!(
|
|
evaluate(&frozen, &frozen.cases[0], "total")?
|
|
.origins
|
|
.values()
|
|
.all(|origin| origin.starts_with("frozen:"))
|
|
);
|
|
checks += 2;
|
|
let mut missing = original.clone();
|
|
missing.cases[19]
|
|
.bindings
|
|
.insert("varying01".into(), Expr::Missing);
|
|
validate(&missing)?;
|
|
assert_eq!(
|
|
evaluate(&missing, &missing.cases[19], "budget")?.value,
|
|
Value::Missing(BTreeSet::from(["varying01".into()]))
|
|
);
|
|
checks += 1;
|
|
write_fixture(dir, &format!("{name}-missing"), &missing)?;
|
|
let frozen_missing = freeze(&missing, 19);
|
|
assert_eq!(
|
|
evaluate(&frozen_missing, &frozen_missing.cases[0], "budget")?.value,
|
|
Value::Missing(BTreeSet::from(["varying01".into()]))
|
|
);
|
|
checks += 1;
|
|
missing.cases[19].bindings.remove("varying01");
|
|
if mode == 'B' {
|
|
validate(&missing)?;
|
|
assert_total(&missing, 19, 22225);
|
|
checks += 1;
|
|
write_fixture(dir, &format!("{name}-cleared-override"), &missing)?;
|
|
} else {
|
|
assert!(
|
|
validate(&missing)
|
|
.unwrap_err()
|
|
.contains("missing binding row")
|
|
);
|
|
checks += 1;
|
|
std::fs::write(
|
|
dir.join(format!("{name}-deleted-row.rejected-study")),
|
|
render(&missing),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
let mut cyclic = original.clone();
|
|
cyclic.cases[19]
|
|
.bindings
|
|
.insert("varying01".into(), Expr::Ref("total".into()));
|
|
let cycle = validate(&cyclic).unwrap_err();
|
|
assert!(cycle.contains("cycle"));
|
|
checks += 1;
|
|
std::fs::write(
|
|
dir.join(format!("{name}-cycle.rejected-study")),
|
|
render(&cyclic),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let observation = evaluate(&original, &original.cases[19], "budget")?;
|
|
let mut detail = format!(
|
|
"# {name}: case20 evaluation\n\nValue: {:?}\nDocument revision: {}\n\n",
|
|
observation.value, observation.document_revision
|
|
);
|
|
for (p, origin) in &observation.origins {
|
|
writeln!(detail, "- {p}: {origin}").unwrap();
|
|
}
|
|
writeln!(detail, "\nRejected cycle: {cycle}").unwrap();
|
|
std::fs::write(dir.join(format!("{name}-provenance.md")), detail)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = original.baseline.as_ref().map_or(0, |b| b.bindings.len())
|
|
+ original
|
|
.cases
|
|
.iter()
|
|
.map(|c| c.bindings.len())
|
|
.sum::<usize>();
|
|
let (a, b) = diff(&initial, &fixed_source);
|
|
let (c, d) = diff(&initial, &threshold_source);
|
|
writeln!(
|
|
report,
|
|
"| {name} | {} | {} | {rows} | {a}/{b} | {c}/{d} | 4 | 1 |",
|
|
initial.lines().count(),
|
|
initial.len()
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
// Negative controls establish that source locality and isolation differ.
|
|
let mut d = fixture('D', false);
|
|
change_fixed(&mut d, false);
|
|
validate(&d)?;
|
|
assert_total(&d, 0, 21950);
|
|
assert_total(&d, 19, 23175);
|
|
checks += 2;
|
|
write_fixture(dir, "D-inline-fixed-one-only", &d)?;
|
|
let b = fixture('B', false);
|
|
let baseline_case = Case {
|
|
name: "wrong_baseline_context".into(),
|
|
base: None,
|
|
bindings: b.baseline.as_ref().unwrap().bindings.clone(),
|
|
};
|
|
assert_eq!(
|
|
evaluate(&b, &baseline_case, "total")?.value,
|
|
Value::Int(21750)
|
|
);
|
|
assert_total(&b, 19, 23175);
|
|
checks += 2;
|
|
let mut explicit = fixture('B', false);
|
|
explicit
|
|
.baseline
|
|
.as_mut()
|
|
.unwrap()
|
|
.bindings
|
|
.insert("varying01".into(), Expr::Missing);
|
|
explicit.cases[19].bindings.remove("varying01");
|
|
validate(&explicit)?;
|
|
assert!(matches!(
|
|
evaluate(&explicit, &explicit.cases[19], "total")?.value,
|
|
Value::Missing(_)
|
|
));
|
|
checks += 1;
|
|
let mut chained = fixture('B', false);
|
|
chained.cases[1].base = Some("case01".into());
|
|
assert!(validate(&chained).unwrap_err().contains("case-to-case"));
|
|
checks += 1;
|
|
let mut capture = fixture('D', true);
|
|
capture.functions.get_mut("budget_formula").unwrap().body = Expr::Ref("fixed01".into());
|
|
assert!(validate(&capture).unwrap_err().contains("captures"));
|
|
checks += 1;
|
|
let mut recursive = fixture('D', true);
|
|
recursive.functions.get_mut("budget_formula").unwrap().body =
|
|
Expr::Call("budget_formula".into(), vec![Expr::Ref("result".into())]);
|
|
assert!(validate(&recursive).unwrap_err().contains("cycle"));
|
|
checks += 1;
|
|
writeln!(report,"\nExecuted assertions: {checks}. All passed. Every accepted fixture was parsed, validated, rendered and parsed back with exact experiment AST equality. Rejected cases were checked separately.\n").unwrap();
|
|
report.push_str("Negative controls: D changing one repeated fixed input changes only that case; B evaluating inherited formulas against baseline inputs instead of effective case inputs gives 21,750 instead of case20's 23,175. A shallow B case copy is not frozen; factored D also needs its definition environment snapshotted. Clearing a B override restores its named baseline value, including an explicitly missing baseline. A deleted D binding row rejects. Baseline chains, recursive definitions and project captures inside pure functions reject.\n");
|
|
std::fs::write(dir.join("RESULTS.md"), &report).map_err(|e| e.to_string())?;
|
|
println!("{report}");
|
|
Ok(())
|
|
}
|