use crate::{
Blocker, RuleStatus, VALIDATION_RULE_SPEC_PATH, VALIDATION_RULE_SPEC_VERSION,
ValidationRuleStatus,
};
pub fn render_conformance_report(statuses: &[ValidationRuleStatus]) -> String {
let mut out = String::new();
render_header(&mut out);
render_headline_coverage(&mut out, statuses);
render_status_taxonomy(&mut out, statuses);
render_machine_checkable_gaps(&mut out, statuses);
render_runtime_coverage_signal(&mut out);
render_per_status_section(
&mut out,
statuses,
RuleStatus::Error,
"Error",
"Algorithm complete; MUST violations emit as `Severity::Error`.",
);
render_per_status_section(
&mut out,
statuses,
RuleStatus::Warning,
"Warning",
"Algorithm complete; SHOULD violations emit as `Severity::Warning`.",
);
render_configurable_section(&mut out, statuses);
render_machine_uncheckable_section(&mut out, statuses);
render_per_status_section(
&mut out,
statuses,
RuleStatus::Unimplemented,
"Unimplemented",
"No local algorithm yet. The `Blocker` column names what's needed.",
);
render_profile_section(&mut out);
out
}
fn render_header(out: &mut String) {
out.push_str("# SBOL 3.1.0 Conformance Report\n\n");
out.push_str(
"This file is generated by `cargo run -p sbol --bin generate-conformance-report`.\n",
);
out.push_str(
"It is committed and CI runs `git diff --exit-code docs/conformance.md` to enforce\n",
);
out.push_str("freshness after every change to `validation_rule_statuses()`.\n\n");
out.push_str(&format!(
"Source spec: `{VALIDATION_RULE_SPEC_PATH}` (version {VALIDATION_RULE_SPEC_VERSION}).\n\n"
));
}
fn render_headline_coverage(out: &mut String, statuses: &[ValidationRuleStatus]) {
let total = statuses.len();
let triangle = statuses
.iter()
.filter(|s| s.is_machine_uncheckable())
.count();
let machine_checkable = total - triangle;
let implemented = statuses
.iter()
.filter(|s| s.is_implemented() && s.is_machine_checkable())
.count();
let pct = (implemented as f64 / machine_checkable as f64) * 100.0;
let unimplemented = machine_checkable - implemented;
out.push_str("## Headline coverage\n\n");
out.push_str(&format!(
"**{implemented} of {machine_checkable} machine-checkable spec rules are fully \
implemented ({pct:.1}%).**\n\n"
));
out.push_str(
"The catalog tracks all 149 SBOL 3.1.0 validation rules. Appendix B (p.2837–2840) \
marks 40 of them with the \u{25B2} symbol: weak-REQUIRED conditions whose violations \
are NOT to be machine-reported. Those are tracked separately from the headline \
percentage: the runtime signals them as `RuleCoverage::not_applied { MachineUncheckable }`, \
and the validator emits at most warnings (never errors) when its local subset can \
decide a sub-case.\n\n",
);
out.push_str(&format!(
"| | Count |\n| --- | --- |\n\
| Total rules in spec | {total} |\n\
| \u{25B2} machine-uncheckable per Appendix B | {triangle} |\n\
| Machine-checkable | {machine_checkable} |\n\
| \u{2192} fully implemented | **{implemented}** |\n\
| \u{2192} not yet implemented | **{unimplemented}** |\n\n"
));
}
fn render_status_taxonomy(out: &mut String, statuses: &[ValidationRuleStatus]) {
out.push_str("## Status taxonomy\n\n");
out.push_str(
"Every rule classifies into exactly one of five statuses. The `Blocker` column carries \
the secondary axis where it applies.\n\n",
);
out.push_str("| Status | Count | Algorithm | Diagnostics | Blocker meaning |\n");
out.push_str("| --- | --- | --- | --- | --- |\n");
for (status, algorithm, diagnostics, blocker_meaning) in [
(RuleStatus::Error, "Complete", "Errors", "(none)"),
(RuleStatus::Warning, "Complete", "Warnings", "(none)"),
(
RuleStatus::Configurable,
"Complete; scope or severity varies by config",
"Errors / Warnings per config",
"Which configuration axis: Resolver, Ontology snapshot, Policy, or External",
),
(
RuleStatus::MachineUncheckable,
"May have local subset",
"Warnings only, on positively-decidable cases",
"Why the spec marks it \u{25B2}: Ontology, Policy, or External",
),
(
RuleStatus::Unimplemented,
"None",
"Never emits",
"What's needed: Ontology data, Resolver protocol, Policy decision",
),
] {
let count = statuses.iter().filter(|s| s.status == status).count();
out.push_str(&format!(
"| {} | {count} | {algorithm} | {diagnostics} | {blocker_meaning} |\n",
status_label(status),
));
}
out.push_str(&format!("| **Total** | **{}** | | | |\n\n", statuses.len()));
}
fn render_machine_checkable_gaps(out: &mut String, statuses: &[ValidationRuleStatus]) {
let gaps: Vec<&ValidationRuleStatus> = statuses
.iter()
.filter(|s| s.is_machine_checkable() && !s.is_implemented())
.collect();
out.push_str("## Machine-checkable gaps\n\n");
if gaps.is_empty() {
out.push_str("None. Every machine-checkable rule is fully implemented.\n\n");
return;
}
out.push_str(&format!(
"{} machine-checkable rules are `Unimplemented`. Each one's blocker names what's \
needed.\n\n",
gaps.len()
));
out.push_str("| Rule | Blocker | Severity | Why it's a gap |\n");
out.push_str("| --- | --- | --- | --- |\n");
for entry in gaps {
let blocker = entry.blocker.map(blocker_label).unwrap_or("(none)");
let severity = match entry.normative_severity {
crate::NormativeSeverity::Must => "MUST",
crate::NormativeSeverity::Should => "SHOULD",
crate::NormativeSeverity::May => "MAY",
};
let note = entry.note.replace('|', "\\|");
out.push_str(&format!(
"| `{}` | `{blocker}` | {severity} | {note} |\n",
entry.rule
));
}
out.push('\n');
}
fn render_configurable_section(out: &mut String, statuses: &[ValidationRuleStatus]) {
let entries: Vec<&ValidationRuleStatus> = statuses
.iter()
.filter(|s| s.status == RuleStatus::Configurable)
.collect();
if entries.is_empty() {
return;
}
out.push_str("## Configurable\n\n");
out.push_str(&format!(
"**{} rules.** Behavior depends on configuration. The `Blocker` column names the axis:\n\n\
- `Resolver`: algorithm needs `--external-mode provided|allowed` for full scope. \
Without one, coverage records `LocalReferencesOnly`.\n\
- `Ontology`: algorithm correct for every snapshot-known term; out-of-snapshot terms \
left undecided by design.\n\
- `Policy`: algorithm runs at the `Conservative` `PolicyOptions` default; `Strict` and \
`Lenient` modes alter emit behavior. See `docs/policies/<id>.md`.\n\
- `External`: local-only mode is the implementation. The spec scope is structurally \
unreachable without external infrastructure (a global registry); the validator does \
what it can locally and documents the boundary.\n\n",
entries.len()
));
out.push_str("### By configuration axis\n\n");
out.push_str("| Axis | Count |\n| --- | --- |\n");
for blocker in [
Blocker::Resolver,
Blocker::Ontology,
Blocker::Policy,
Blocker::External,
] {
let count = entries
.iter()
.filter(|e| e.blocker == Some(blocker))
.count();
if count > 0 {
out.push_str(&format!("| `{}` | {count} |\n", blocker_label(blocker)));
}
}
out.push('\n');
out.push_str("| Rule | Axis | Note |\n");
out.push_str("| --- | --- | --- |\n");
for entry in entries {
let safe_note = entry.note.replace('|', "\\|");
let blocker = entry.blocker.map(blocker_label).unwrap_or("(unspecified)");
out.push_str(&format!(
"| `{}` | `{blocker}` | {safe_note} |\n",
entry.rule
));
}
out.push('\n');
}
fn render_machine_uncheckable_section(out: &mut String, statuses: &[ValidationRuleStatus]) {
let entries: Vec<&ValidationRuleStatus> = statuses
.iter()
.filter(|s| s.is_machine_uncheckable())
.collect();
if entries.is_empty() {
return;
}
out.push_str("## \u{25B2} MachineUncheckable\n\n");
out.push_str(&format!(
"**{} rules.** Appendix B \u{25B2}: violations are NOT to be machine-reported. The \
validator runs a local subset for some of these and emits warnings on \
positively-decidable cases; the broader spec rule is always recorded in \
`RuleCoverage::not_applied {{ MachineUncheckable }}`.\n\n",
entries.len()
));
let with_validator = entries
.iter()
.filter(|e| e.validator_function.is_some())
.count();
let without = entries.len() - with_validator;
out.push_str(&format!(
"| Local subset? | Count |\n| --- | --- |\n\
| Yes, emits warnings on positively-decidable cases | {with_validator} |\n\
| No, coverage signal only | {without} |\n\n"
));
out.push_str("| Rule | Local subset? | Blocker | Note |\n");
out.push_str("| --- | --- | --- | --- |\n");
for entry in entries {
let safe_note = entry.note.replace('|', "\\|");
let blocker = entry.blocker.map(blocker_label).unwrap_or("(none)");
let subset = if entry.validator_function.is_some() {
"Yes"
} else {
"No"
};
out.push_str(&format!(
"| `{}` | {subset} | `{blocker}` | {safe_note} |\n",
entry.rule
));
}
out.push('\n');
}
fn render_runtime_coverage_signal(out: &mut String) {
out.push_str("## Runtime coverage signal\n\n");
out.push_str("Every `ValidationReport` carries a `RuleCoverage` value with three buckets:\n\n");
out.push_str(
"- `fully_applied`: every spec case for this rule is decidable with the current \
configuration and was evaluated against this document.\n",
);
out.push_str(
"- `partially_applied`: the local subset ran but the rule's full coverage is \
configuration- or snapshot-bounded. Each entry carries a `coverage_kind`: \
`OntologyKnownTermsOnly`, `LocalReferencesOnly`, `LexicalShapeOnly`, or \
`PolicyDefaultUndecided`.\n",
);
out.push_str(
"- `not_applied`: either the rule is `Unimplemented` (no local algorithm; \
`reason: Deferred(blocker)`) or it is a \u{25B2} rule whose violations the spec \
asks tools not to report (`reason: MachineUncheckable`).\n\n",
);
out.push_str(
"Callers wanting a strict CI gate use `Document::check_complete` (or the CLI's \
`--treat-partial-as-errors`) which returns an error whenever `partially_applied` is \
non-empty. The default `Document::check` returns errors only on hard diagnostics, \
leaving coverage gaps observable but non-fatal.\n\n",
);
}
fn render_per_status_section(
out: &mut String,
statuses: &[ValidationRuleStatus],
status: RuleStatus,
title: &str,
description: &str,
) {
let entries: Vec<&ValidationRuleStatus> =
statuses.iter().filter(|s| s.status == status).collect();
if entries.is_empty() {
return;
}
let count = entries.len();
out.push_str(&format!("## {title}\n\n"));
out.push_str(&format!(
"**{count} rule{}.** {description}\n\n",
plural(count)
));
let needs_blocker_column = entries.iter().any(|e| e.blocker.is_some());
if needs_blocker_column {
out.push_str("| Rule | Blocker | Note |\n");
out.push_str("| --- | --- | --- |\n");
for entry in entries {
let safe_note = entry.note.replace('|', "\\|");
let blocker = entry.blocker.map(blocker_label).unwrap_or("");
let blocker_cell = if blocker.is_empty() {
String::new()
} else {
format!("`{blocker}`")
};
out.push_str(&format!(
"| `{}` | {blocker_cell} | {safe_note} |\n",
entry.rule
));
}
} else {
out.push_str("| Rule | Note |\n");
out.push_str("| --- | --- |\n");
for entry in entries {
let safe_note = entry.note.replace('|', "\\|");
out.push_str(&format!("| `{}` | {safe_note} |\n", entry.rule));
}
}
out.push('\n');
}
fn status_label(status: RuleStatus) -> &'static str {
match status {
RuleStatus::Error => "`Error`",
RuleStatus::Warning => "`Warning`",
RuleStatus::Configurable => "`Configurable`",
RuleStatus::MachineUncheckable => "`MachineUncheckable`",
RuleStatus::Unimplemented => "`Unimplemented`",
}
}
fn blocker_label(blocker: Blocker) -> &'static str {
match blocker {
Blocker::Ontology => "Ontology",
Blocker::Resolver => "Resolver",
Blocker::StrictDatatype => "StrictDatatype",
Blocker::Policy => "Policy",
Blocker::External => "External",
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn render_profile_section(out: &mut String) {
out.push_str("## Compliance profile claims\n\n");
out.push_str("These claims are provisional and describe what is currently exercised by the test suite. Release-grade profile boundaries will be finalized before the first crates.io publish.\n\n");
out.push_str("- **Structural import / export**: Turtle parse and serialize for the SBOLTestSuite fixture corpus is exercised by `representative_fixtures_round_trip_as_normalized_rdf` in `crates/sbol/tests/sbol3_fixtures.rs`.\n");
out.push_str("- **Lossless RDF round-trip**: Parse → write → reparse yields equal normalized triple sets for every representative fixture.\n");
out.push_str("- **Typed round-trip with extension preservation**: `typed_round_trip_preserves_extension_triples_on_typed_objects` verifies that non-SBOL/PROV/OM predicates survive a typed rebuild.\n");
out.push_str("- **Cross-implementation conformance vs libSBOLj3**: 33 SBOLTestSuite SBOL3 fixtures (the full valid-input set in `tests/sbol3_fixtures_manifest.tsv`) produce normalized triple sets identical to libSBOLj3 1.0.5.2 reference output. CI runs `cargo test --test cross_impl` against committed `*.libSBOLj3.expected.ttl` files in `tests/fixtures/cross-impl/`.\n");
out.push_str("- **Property-based testing**: `crates/sbol/tests/properties.rs` exercises eight spec-derived properties under `proptest` at the default case count, including catalog coverage partitioning and override-builder semantics.\n");
out.push_str("- **Fuzz coverage**: `cargo fuzz run read_turtle`, `cargo fuzz run round_trip`, `cargo fuzz run validate`, and `cargo fuzz run round_trip_validate` (see `fuzz/`) exercise the parser and validator under libFuzzer. CI smoke runs the parser targets for 60s each; the validator targets are local-only until corpus and budget are tuned.\n\n");
out.push_str("## Per-rule regression cases\n\n");
out.push_str("Every rule with an algorithm has at least one regression case in `crates/sbol/tests/rule_cases/`, organized by spec section. The catalog is enforced by `implemented_validation_rules_have_regression_cases` and `validation_rule_regression_cases_report_expected_rule_ids` in `crates/sbol/tests/validation_rules.rs`.\n\n");
out.push_str("Selected high-value clusters also have positive cases (the rule must NOT fire on a valid example), verified by `positive_rule_cases_do_not_report_their_rule`.\n");
}