///|
/// Schema constraints inferred or supplied for a signal.
pub enum SchemaConstraint {
ScalarOnly
VectorOnly
RealOnly
WidthExact
WidthAtMost
KnownValuesOnly
MonotonicTime
} derive(Debug, Eq)
///|
/// A versioned signal schema entry.
pub struct SignalSchema {
signal_id : String
path : String
declared_width : Int
observed_width : Int
kind : String
constraints : Array[SchemaConstraint]
samples : Int
valid : Bool
} derive(Debug)
///|
/// Result of validating a trace against a schema.
pub struct SchemaFinding {
signal_id : String
constraint : SchemaConstraint
valid : Bool
observed : String
expected : String
message : String
} derive(Debug)
///|
/// A schema snapshot and validation findings.
pub struct TraceSchemaReport {
version : String
entries : Array[SignalSchema]
findings : Array[SchemaFinding]
valid : Bool
} derive(Debug)
///|
/// List supported schema constraints.
pub fn schema_constraint_catalog() -> Array[SchemaConstraint] {
[
ScalarOnly,
VectorOnly,
RealOnly,
WidthExact,
WidthAtMost,
KnownValuesOnly,
MonotonicTime,
]
}
///|
/// Infer a schema from declarations and observed values.
pub fn infer_trace_schema(trace : Trace) -> TraceSchemaReport {
let entries : Array[SignalSchema] = []
for signal in trace.signals {
let changes = trace.changes.filter(fn(item) { item.id == signal.id })
let observed_width = changes.fold(init=0, fn(current, item) {
Int::max(current, item.value.width())
})
let constraints = schema_constraints_for(signal, changes)
entries.push({
signal_id: signal.id,
path: signal_path(signal),
declared_width: Int::max(1, signal.width),
observed_width: Int::max(1, observed_width),
kind: signal.kind,
constraints,
samples: changes.length(),
valid: schema_entry_valid(signal, changes),
})
}
let findings = schema_findings(trace, entries)
{
version: "1.0",
entries,
findings,
valid: findings.filter(fn(item) { !item.valid }).length() == 0,
}
}
///|
/// Validate a trace against an expected schema report.
pub fn validate_trace_schema(
trace : Trace,
expected : TraceSchemaReport,
) -> TraceSchemaReport {
let actual = infer_trace_schema(trace)
let findings = actual.findings.copy()
for entry in expected.entries {
match schema_find_entry(actual.entries, entry.signal_id) {
Some(current) =>
if current.declared_width != entry.declared_width {
findings.push({
signal_id: entry.signal_id,
constraint: WidthExact,
valid: false,
observed: current.declared_width.to_string(),
expected: entry.declared_width.to_string(),
message: "declared width changed",
})
}
None =>
findings.push({
signal_id: entry.signal_id,
constraint: WidthExact,
valid: false,
observed: "missing",
expected: "present",
message: "schema signal is absent",
})
}
}
{
..actual,
findings,
valid: findings.filter(fn(item) { !item.valid }).length() == 0,
}
}
///|
/// Find a schema entry without allocating a filtered array.
fn schema_find_entry(
entries : Array[SignalSchema],
signal_id : String,
) -> SignalSchema? {
for entry in entries {
if entry.signal_id == signal_id {
return Some(entry)
}
}
None
}
///|
/// Return a schema entry by signal identifier.
pub fn TraceSchemaReport::find(
self : TraceSchemaReport,
signal_id : String,
) -> SignalSchema? {
for entry in self.entries {
if entry.signal_id == signal_id {
return Some(entry)
}
}
None
}
///|
/// Return the number of schema violations.
pub fn TraceSchemaReport::violation_count(self : TraceSchemaReport) -> Int {
self.findings.filter(fn(item) { !item.valid }).length()
}
///|
/// Export schema entries as CSV.
pub fn schema_entries_csv(entries : Array[SignalSchema]) -> String {
let out = StringBuilder()
out.write_string(
"signal,path,declared_width,observed_width,kind,samples,valid,constraints\n",
)
for entry in entries {
out.write_string(
escape_text(entry.signal_id) +
"," +
escape_text(entry.path) +
"," +
entry.declared_width.to_string() +
"," +
entry.observed_width.to_string() +
"," +
escape_text(entry.kind) +
"," +
entry.samples.to_string() +
"," +
entry.valid.to_string() +
"," +
entry.constraints.map(schema_constraint_name).join("|") +
"\n",
)
}
out.to_string()
}
///|
/// Export schema findings as CSV.
pub fn schema_findings_csv(findings : Array[SchemaFinding]) -> String {
let out = StringBuilder()
out.write_string("signal,constraint,valid,observed,expected,message\n")
for finding in findings {
out.write_string(
escape_text(finding.signal_id) +
"," +
schema_constraint_name(finding.constraint) +
"," +
finding.valid.to_string() +
"," +
escape_text(finding.observed) +
"," +
escape_text(finding.expected) +
"," +
escape_text(finding.message) +
"\n",
)
}
out.to_string()
}
///|
/// Render a schema report as a compact text summary.
pub fn TraceSchemaReport::to_text(self : TraceSchemaReport) -> String {
"schema=" +
self.version +
" entries=" +
self.entries.length().to_string() +
" findings=" +
self.findings.length().to_string() +
" valid=" +
self.valid.to_string()
}
///|
/// Render schema metadata as JSON.
pub fn TraceSchemaReport::to_json(self : TraceSchemaReport) -> String {
let entries = self.entries.map(schema_entry_json).join(",")
let findings = self.findings.map(schema_finding_json).join(",")
"{\"version\":\"" +
self.version +
"\",\"valid\":" +
self.valid.to_string() +
",\"entries\":[" +
entries +
"],\"findings\":[" +
findings +
"]}"
}
///|
/// Infer constraints from value kinds, widths and ordering.
fn schema_constraints_for(
signal : Signal,
changes : Array[Change],
) -> Array[SchemaConstraint] {
let result : Array[SchemaConstraint] = [WidthExact, MonotonicTime]
let mut scalar = false
let mut vector = false
let mut real = false
let mut known = true
for change in changes {
match change.value {
Scalar(_) => scalar = true
Vector(_) => vector = true
Real(_) => real = true
}
if change.value.is_unknown() {
known = false
}
}
if scalar && !vector && !real {
result.push(ScalarOnly)
}
if vector && !scalar && !real {
result.push(VectorOnly)
}
if real && !scalar && !vector {
result.push(RealOnly)
}
if known {
result.push(KnownValuesOnly)
}
if signal.width <= 1 && vector {
result.push(WidthAtMost)
}
result
}
///|
/// Validate observed values against a declaration.
fn schema_entry_valid(signal : Signal, changes : Array[Change]) -> Bool {
changes.all(fn(change) { change.value.width() <= Int::max(1, signal.width) })
}
///|
/// Produce findings for every inferred schema entry.
fn schema_findings(
trace : Trace,
entries : Array[SignalSchema],
) -> Array[SchemaFinding] {
let result : Array[SchemaFinding] = []
for entry in entries {
if !entry.valid {
result.push({
signal_id: entry.signal_id,
constraint: WidthExact,
valid: false,
observed: entry.observed_width.to_string(),
expected: entry.declared_width.to_string(),
message: "observed value exceeds declaration",
})
}
let changes = trace.changes.filter(fn(item) { item.id == entry.signal_id })
let mut previous : Int64? = None
for change in changes {
match previous {
Some(time) if change.time < time =>
result.push({
signal_id: entry.signal_id,
constraint: MonotonicTime,
valid: false,
observed: change.time.to_string(),
expected: time.to_string(),
message: "change timestamps are not monotonic",
})
_ => ()
}
previous = Some(change.time)
}
}
result
}
///|
/// Name a schema constraint.
pub fn schema_constraint_name(value : SchemaConstraint) -> String {
match value {
ScalarOnly => "scalar-only"
VectorOnly => "vector-only"
RealOnly => "real-only"
WidthExact => "width-exact"
WidthAtMost => "width-at-most"
KnownValuesOnly => "known-values-only"
MonotonicTime => "monotonic-time"
}
}
///|
/// Encode an entry as JSON.
fn schema_entry_json(entry : SignalSchema) -> String {
"{\"signal\":\"" +
escape_text(entry.signal_id) +
"\",\"path\":\"" +
escape_text(entry.path) +
"\",\"declared_width\":" +
entry.declared_width.to_string() +
",\"observed_width\":" +
entry.observed_width.to_string() +
",\"kind\":\"" +
escape_text(entry.kind) +
"\",\"samples\":" +
entry.samples.to_string() +
",\"valid\":" +
entry.valid.to_string() +
"}"
}
///|
/// Encode a finding as JSON.
fn schema_finding_json(item : SchemaFinding) -> String {
"{\"signal\":\"" +
escape_text(item.signal_id) +
"\",\"constraint\":\"" +
schema_constraint_name(item.constraint) +
"\",\"valid\":" +
item.valid.to_string() +
",\"observed\":\"" +
escape_text(item.observed) +
"\",\"expected\":\"" +
escape_text(item.expected) +
"\",\"message\":\"" +
escape_text(item.message) +
"\"}"
}