///|
pub struct BatchCase {
name : String
input : String
format : String
threshold : Double?
auto_threshold : Bool
invert : Bool
} derive(@debug.Debug)
///|
pub extend BatchCase with @debug.Debug::{to_repr}
///|
pub struct BatchPair {
name : String
left : String
right : String
} derive(@debug.Debug)
///|
pub extend BatchPair with @debug.Debug::{to_repr}
///|
pub struct BatchPlan {
cases : Array[BatchCase]
comparisons : Array[BatchPair]
all_pairs : Bool
} derive(@debug.Debug)
///|
pub extend BatchPlan with @debug.Debug::{to_repr}
///|
fn batch_string(
fields : Map[String, Json],
field : String,
) -> String raise TopologyError {
@json.from_json(required_field(fields, field)) catch {
_ => raise TopologyError("batch field " + field + " must be a string")
}
}
///|
fn batch_name(value : String) -> Unit raise TopologyError {
if value == "" || value.length() > 32 {
raise TopologyError(
"batch names must contain 1..32 ASCII letters, digits, - or _",
)
}
for c in value {
let code = c.to_int()
if !((code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code == 45 ||
code == 95) {
raise TopologyError(
"batch names must contain 1..32 ASCII letters, digits, - or _",
)
}
}
}
///|
fn batch_object(
value : Json,
label : String,
) -> Map[String, Json] raise TopologyError {
match value {
Object(fields) => fields
_ => raise TopologyError(label + " must be an object")
}
}
///|
fn batch_array(
fields : Map[String, Json],
field : String,
required : Bool,
) -> Array[Json] raise TopologyError {
match fields.get(field) {
Some(Array(items)) => items
Some(_) => raise TopologyError(field + " must be an array")
None =>
if required {
raise TopologyError("missing field: " + field)
} else {
[]
}
}
}
///|
fn batch_fields(
fields : Map[String, Json],
allowed : Array[String],
label : String,
) -> Unit raise TopologyError {
for key in fields.keys() {
if !allowed.contains(key) {
raise TopologyError("unknown " + label + " field: " + key)
}
}
}
///|
fn batch_bool(
fields : Map[String, Json],
field : String,
fallback : Bool,
) -> Bool raise TopologyError {
match fields.get(field) {
Some(value) => {
let parsed : Bool = @json.from_json(value) catch {
_ => raise TopologyError("batch " + field + " must be boolean")
}
parsed
}
None => fallback
}
}
///|
/// Bounded manifest for up to eight local inputs and twelve comparisons.
/// File paths are resolved and confined by the CLI host adapter.
pub fn parse_batch_manifest(text : String) -> BatchPlan raise TopologyError {
if text.length() > 1000000 {
raise TopologyError("batch manifest exceeds one million characters")
}
let parsed = @json.parse(text) catch {
_ => raise TopologyError("invalid batch manifest JSON")
}
let root = batch_object(parsed, "batch manifest")
batch_fields(root, ["cases", "comparisons", "all_pairs"], "manifest")
let all_pairs = batch_bool(root, "all_pairs", false)
let case_values = batch_array(root, "cases", true)
let pair_values = batch_array(root, "comparisons", false)
if case_values.length() < 1 ||
case_values.length() > 8 ||
pair_values.length() > 12 {
raise TopologyError("batch requires 1..8 cases and at most 12 comparisons")
}
let seen_cases : Map[String, Bool] = Map([])
let cases : Array[BatchCase] = []
for value in case_values {
let fields = batch_object(value, "case")
batch_fields(
fields,
["name", "input", "format", "threshold", "invert"],
"case",
)
let name = batch_string(fields, "name")
batch_name(name)
if seen_cases.contains(name) {
raise TopologyError("duplicate case name: " + name)
}
seen_cases[name] = true
let input = batch_string(fields, "input")
if input == "" || input.length() > 256 {
raise TopologyError("batch input path must contain 1..256 characters")
}
let format = match fields.get("format") {
None => "json"
Some(_) => batch_string(fields, "format")
}
if format != "json" && format != "pgm" {
raise TopologyError("batch format must be json or pgm")
}
let auto_threshold = match fields.get("threshold") {
Some(String("auto")) => true
_ => false
}
let threshold = match fields.get("threshold") {
Some(Number(n, ..)) => Some(n)
Some(String("auto")) => None
Some(_) => raise TopologyError("batch threshold must be numeric or auto")
None => None
}
if (format == "pgm" && threshold is None && !auto_threshold) ||
(format == "json" && fields.contains("threshold")) {
raise TopologyError(
"threshold is required for pgm and forbidden for json cases",
)
}
let invert = batch_bool(fields, "invert", false)
if format == "json" && fields.contains("invert") {
raise TopologyError("invert is only supported for pgm cases")
}
if threshold is Some(n) {
if !finite(n) || n.abs() > 1.0e100 {
raise TopologyError("PGM threshold must be bounded and finite")
}
}
cases.push({ name, input, format, threshold, auto_threshold, invert, })
}
let seen_pairs : Map[String, Bool] = Map([])
let comparisons : Array[BatchPair] = []
for value in pair_values {
let fields = batch_object(value, "comparison")
batch_fields(fields, ["name", "left", "right"], "comparison")
let name = batch_string(fields, "name")
batch_name(name)
if seen_pairs.contains(name) {
raise TopologyError("duplicate comparison name: " + name)
}
seen_pairs[name] = true
let left = batch_string(fields, "left")
let right = batch_string(fields, "right")
if !seen_cases.contains(left) || !seen_cases.contains(right) {
raise TopologyError("comparison references an unknown case: " + name)
}
comparisons.push({ name, left, right, })
}
{ cases, comparisons, all_pairs, }
}