diff --git a/codegen/README.md b/codegen/README.md index 0dc31fd..408738b 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -30,3 +30,40 @@ indentation-aware text composition is natural; Rust remains token-based with needs a distinct root, cleanup root, marker, or formatter. Do not introduce a cross-language syntax AST or let renderers own filesystem, staging, formatting, or cleanup policy. + +## Corpus report + +Generate a human-readable summary of the current specification corpus from the +repository root: + +```sh +mise run corpus-report +``` + +Use `--format json` for a deterministic, machine-readable report suitable for +CI checks and publication tables: + +```sh +mise run corpus-report --format json +``` + +The command uses the normal specification loader, schema and semantic +validation, and compilation path. It counts every schema-valid function, +including `draft` and `blocked` functions. Verification coverage counts declared +`golden_tests` and `edge_cases`; it does not describe external predictive +validation on soil datasets, and a declared edge case is not necessarily an +executable test. + +The JSON document has stable top-level `sources`, `functions`, `verification`, +`inputs`, `outputs`, `scope`, and `blocked_functions` sections. Category tables +are emitted as sorted arrays with explicit counts and percentages. Inputs are +resolved by the specification loader and reported separately as `numeric` or +`categorical`. + +The schema does not have an explicit publication-year field. The report accepts +the final four characters of the APA-style source slug only when all four are +ASCII digits and lists unresolved source slugs separately. It reports the +existing `prediction_target`, `h_theta`, and `k_h` strings without attempting +free-text scientific-property classification. Blocker evidence is retained from +function documentation and source `scientific_notes`, but the current schema +does not provide structured blocker categories. diff --git a/codegen/src/corpus_report.rs b/codegen/src/corpus_report.rs new file mode 100644 index 0000000..6f5bce4 --- /dev/null +++ b/codegen/src/corpus_report.rs @@ -0,0 +1,751 @@ +//! Deterministic corpus-level statistics derived from validated specifications. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::model::{Entry, Input, Outputs}; + +#[derive(Debug, Serialize)] +pub(crate) struct Report { + pub(crate) sources: Sources, + pub(crate) functions: Functions, + pub(crate) verification: Verification, + pub(crate) inputs: Vec, + pub(crate) outputs: OutputsReport, + pub(crate) scope: ScopeReport, + pub(crate) blocked_functions: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Sources { + pub(crate) specification_files: usize, + pub(crate) represented_publications: usize, + pub(crate) earliest_publication_year: Option, + pub(crate) latest_publication_year: Option, + pub(crate) publication_year_derivation: &'static str, + pub(crate) unresolved_publication_year_count: usize, + pub(crate) unresolved_publication_years: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Functions { + pub(crate) total: usize, + pub(crate) by_status: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Verification { + pub(crate) all_functions: VerificationCoverage, + pub(crate) implemented_functions: VerificationCoverage, + pub(crate) edge_case_interpretation: &'static str, +} + +#[derive(Debug, Default, Serialize)] +pub(crate) struct VerificationCoverage { + pub(crate) functions: usize, + pub(crate) golden_tests: usize, + pub(crate) functions_with_golden_tests: usize, + pub(crate) functions_with_golden_tests_percentage: f64, + pub(crate) edge_cases: usize, + pub(crate) functions_with_edge_cases: usize, + pub(crate) functions_with_edge_cases_percentage: f64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct InputFrequency { + pub(crate) name: String, + pub(crate) kind: InputKind, + pub(crate) functions: usize, + pub(crate) percentage: f64, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum InputKind { + Numeric, + Categorical, +} + +#[derive(Debug, Serialize)] +pub(crate) struct OutputsReport { + pub(crate) scalar_functions: usize, + pub(crate) record_functions: usize, + pub(crate) field_names: Vec, + pub(crate) structured_property_grouping_available: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ScopeReport { + pub(crate) prediction_targets: Vec, + pub(crate) models: ModelsReport, + pub(crate) calibration_geography: GeographyReport, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ModelsReport { + pub(crate) h_theta: Vec, + pub(crate) k_h: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct GeographyReport { + pub(crate) source_specifications_with_territory: usize, + pub(crate) functions_with_territory: usize, + pub(crate) territories: Vec, + pub(crate) source_specifications_with_dataset: usize, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TerritoryFrequency { + pub(crate) territory: String, + pub(crate) source_specifications: usize, + pub(crate) functions: usize, +} + +#[derive(Debug, Serialize)] +pub(crate) struct BlockedFunction { + pub(crate) source_identifier: String, + pub(crate) function_name: String, + pub(crate) documentation_notes: Vec, + pub(crate) documentation_warnings: Vec, + pub(crate) scientific_notes: Option, + pub(crate) blocker_classification: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Frequency { + pub(crate) value: String, + pub(crate) count: usize, + pub(crate) percentage: f64, +} + +impl Report { + pub(crate) fn from_entries(entries: &[Entry]) -> Self { + let total_functions = entries.iter().map(|entry| entry.spec.functions.len()).sum(); + let represented_publications = entries + .iter() + .map(|entry| entry.spec.source.citation_apa.as_str()) + .collect::>() + .len(); + let mut years = Vec::new(); + let mut unresolved_publication_years = Vec::new(); + for entry in entries { + match publication_year(&entry.slug) { + Some(year) => years.push(year), + None => unresolved_publication_years.push(entry.slug.clone()), + } + } + + let mut statuses = BTreeMap::new(); + let mut inputs = BTreeMap::new(); + let mut prediction_targets = BTreeMap::new(); + let mut h_theta = BTreeMap::new(); + let mut k_h = BTreeMap::new(); + let mut output_fields = BTreeMap::new(); + let mut scalar_functions = 0; + let mut record_functions = 0; + let mut all_verification = VerificationCoverage::default(); + let mut implemented_verification = VerificationCoverage::default(); + let mut function_territories = BTreeMap::new(); + let mut source_territories = BTreeMap::new(); + let mut functions_with_territory = 0; + let mut blocked_functions = Vec::new(); + + for entry in entries { + if let Some(territory) = &entry.spec.scope.territory { + increment(&mut source_territories, territory); + } + for function in &entry.spec.functions { + increment(&mut statuses, &function.status); + increment(&mut prediction_targets, &function.scope.prediction_target); + if let Some(model) = &function.scope.models.h_theta { + increment(&mut h_theta, model); + } + if let Some(model) = &function.scope.models.k_h { + increment(&mut k_h, model); + } + if let Some(territory) = &function.scope.territory { + functions_with_territory += 1; + increment(&mut function_territories, territory); + } + + let unique_inputs = function + .inputs + .iter() + .map(|input| { + let kind = match input { + Input::Parameter(_) => InputKind::Numeric, + Input::Enum { .. } => InputKind::Categorical, + }; + (input.name().to_owned(), kind) + }) + .collect::>(); + for input in unique_inputs { + *inputs.entry(input).or_default() += 1; + } + + match &function.outputs { + Outputs::Scalar { .. } => scalar_functions += 1, + Outputs::Record { .. } => record_functions += 1, + } + for field in function.outputs.fields() { + increment(&mut output_fields, &field.name); + } + + add_verification(&mut all_verification, function); + if function.status == "implemented" { + add_verification(&mut implemented_verification, function); + } + if function.status == "blocked" { + blocked_functions.push(BlockedFunction { + source_identifier: entry.slug.clone(), + function_name: function.name.clone(), + documentation_notes: function.documentation.notes.clone(), + documentation_warnings: function.documentation.warnings.clone(), + scientific_notes: (!entry.spec.scientific_notes.is_empty()) + .then(|| entry.spec.scientific_notes.clone()), + blocker_classification: None, + }); + } + } + } + finish_verification(&mut all_verification); + finish_verification(&mut implemented_verification); + blocked_functions.sort_by(|left, right| { + (&left.source_identifier, &left.function_name) + .cmp(&(&right.source_identifier, &right.function_name)) + }); + + let territory_names = source_territories + .keys() + .chain(function_territories.keys()) + .cloned() + .collect::>(); + let territories = territory_names + .into_iter() + .map(|territory| TerritoryFrequency { + source_specifications: source_territories + .get(&territory) + .copied() + .unwrap_or_default(), + functions: function_territories + .get(&territory) + .copied() + .unwrap_or_default(), + territory, + }) + .collect(); + + Self { + sources: Sources { + specification_files: entries.len(), + represented_publications, + earliest_publication_year: years.iter().min().copied(), + latest_publication_year: years.iter().max().copied(), + publication_year_derivation: "final four characters of the APA-style source slug, when they are four ASCII digits", + unresolved_publication_year_count: unresolved_publication_years.len(), + unresolved_publication_years, + }, + functions: Functions { + total: total_functions, + by_status: status_frequencies(statuses, total_functions), + }, + verification: Verification { + all_functions: all_verification, + implemented_functions: implemented_verification, + edge_case_interpretation: "declared specification metadata; not a claim that the cases are executable or externally validated", + }, + inputs: sorted_inputs(inputs, total_functions), + outputs: OutputsReport { + scalar_functions, + record_functions, + field_names: frequencies(output_fields, total_functions), + structured_property_grouping_available: false, + }, + scope: ScopeReport { + prediction_targets: frequencies(prediction_targets, total_functions), + models: ModelsReport { + h_theta: frequencies(h_theta, total_functions), + k_h: frequencies(k_h, total_functions), + }, + calibration_geography: GeographyReport { + source_specifications_with_territory: entries + .iter() + .filter(|entry| entry.spec.scope.territory.is_some()) + .count(), + functions_with_territory, + territories, + source_specifications_with_dataset: entries + .iter() + .filter(|entry| entry.spec.scope.dataset.is_some()) + .count(), + }, + }, + blocked_functions, + } + } + + pub(crate) fn to_text(&self) -> String { + let mut output = String::from("ptfkit corpus report\n\nSources\n-------\n"); + output.push_str(&format!( + "Specifications: {}\nRepresented publications: {}\n", + self.sources.specification_files, self.sources.represented_publications + )); + match ( + self.sources.earliest_publication_year, + self.sources.latest_publication_year, + ) { + (Some(first), Some(last)) => { + output.push_str(&format!("Publication years: {first}-{last}\n")); + } + _ => output.push_str("Publication years: unavailable\n"), + } + if !self.sources.unresolved_publication_years.is_empty() { + output.push_str(&format!( + "Unresolved publication years: {}\n", + self.sources.unresolved_publication_years.join(", ") + )); + } + + output.push_str("\nFunctions\n---------\n"); + output.push_str(&format!("Total: {}\n", self.functions.total)); + for status in &self.functions.by_status { + output.push_str(&format!( + "{}: {} ({:.1}%)\n", + status_label(&status.value), + status.count, + status.percentage + )); + } + + output.push_str("\nVerification\n------------\n"); + render_verification( + &mut output, + "All functions", + &self.verification.all_functions, + ); + render_verification( + &mut output, + "Implemented functions", + &self.verification.implemented_functions, + ); + + output.push_str("\nInputs\n------\n"); + for input in &self.inputs { + output.push_str(&format!( + "{} ({}): {} ({:.1}%)\n", + input.name, + match input.kind { + InputKind::Numeric => "numeric", + InputKind::Categorical => "categorical", + }, + input.functions, + input.percentage + )); + } + + output.push_str("\nOutputs\n-------\n"); + output.push_str(&format!( + "Scalar functions: {}\nRecord functions: {}\nOutput fields:\n", + self.outputs.scalar_functions, self.outputs.record_functions + )); + for field in &self.outputs.field_names { + output.push_str(&format!(" {}: {}\n", field.value, field.count)); + } + + output.push_str("\nScope\n-----\nPrediction targets:\n"); + for target in &self.scope.prediction_targets { + output.push_str(&format!(" {}: {}\n", target.value, target.count)); + } + output.push_str("h(theta) models:\n"); + render_values(&mut output, &self.scope.models.h_theta); + output.push_str("K(h) models:\n"); + render_values(&mut output, &self.scope.models.k_h); + let geography = &self.scope.calibration_geography; + output.push_str(&format!( + "Sources with territory: {}\nFunctions with territory: {}\nSources with dataset descriptions: {}\n", + geography.source_specifications_with_territory, + geography.functions_with_territory, + geography.source_specifications_with_dataset + )); + for territory in &geography.territories { + output.push_str(&format!( + " {}: {} sources, {} functions\n", + territory.territory, territory.source_specifications, territory.functions + )); + } + + output.push_str("\nBlocked functions\n-----------------\n"); + if self.blocked_functions.is_empty() { + output.push_str("None\n"); + } else { + for function in &self.blocked_functions { + output.push_str(&format!( + "{} / {}\n", + function.source_identifier, function.function_name + )); + for warning in &function.documentation_warnings { + output.push_str(&format!(" Warning: {warning}\n")); + } + for note in &function.documentation_notes { + output.push_str(&format!(" Note: {note}\n")); + } + if let Some(notes) = &function.scientific_notes { + output.push_str(" Source scientific notes:\n"); + for line in notes.lines() { + output.push_str(&format!(" {line}\n")); + } + } + } + output.push_str("Blocker classification: unavailable from the current schema.\n"); + } + output + } +} + +fn publication_year(slug: &str) -> Option { + let suffix = slug.get(slug.len().checked_sub(4)?..)?; + suffix + .chars() + .all(|character| character.is_ascii_digit()) + .then(|| suffix.parse().ok()) + .flatten() +} + +fn add_verification(coverage: &mut VerificationCoverage, function: &crate::model::Function) { + coverage.functions += 1; + coverage.golden_tests += function.golden_tests.len(); + coverage.edge_cases += function.edge_cases.len(); + coverage.functions_with_golden_tests += usize::from(!function.golden_tests.is_empty()); + coverage.functions_with_edge_cases += usize::from(!function.edge_cases.is_empty()); +} + +fn finish_verification(coverage: &mut VerificationCoverage) { + coverage.functions_with_golden_tests_percentage = + percentage(coverage.functions_with_golden_tests, coverage.functions); + coverage.functions_with_edge_cases_percentage = + percentage(coverage.functions_with_edge_cases, coverage.functions); +} + +fn increment(map: &mut BTreeMap, value: &str) { + *map.entry(value.to_owned()).or_default() += 1; +} + +fn status_frequencies(counts: BTreeMap, total: usize) -> Vec { + const ORDER: [&str; 4] = [ + "implemented", + "ready-for-implementation", + "blocked", + "draft", + ]; + ORDER + .into_iter() + .map(|status| Frequency { + value: status.to_owned(), + count: counts.get(status).copied().unwrap_or_default(), + percentage: percentage(counts.get(status).copied().unwrap_or_default(), total), + }) + .collect() +} + +fn frequencies(counts: BTreeMap, total: usize) -> Vec { + let mut values = counts + .into_iter() + .map(|(value, count)| Frequency { + value, + count, + percentage: percentage(count, total), + }) + .collect::>(); + values.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.value.cmp(&right.value)) + }); + values +} + +fn sorted_inputs( + counts: BTreeMap<(String, InputKind), usize>, + total: usize, +) -> Vec { + let mut inputs = counts + .into_iter() + .map(|((name, kind), functions)| InputFrequency { + name, + kind, + functions, + percentage: percentage(functions, total), + }) + .collect::>(); + inputs.sort_by(|left, right| { + right + .functions + .cmp(&left.functions) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.kind.cmp(&right.kind)) + }); + inputs +} + +fn percentage(count: usize, total: usize) -> f64 { + if total == 0 { + 0.0 + } else { + count as f64 * 100.0 / total as f64 + } +} + +fn status_label(status: &str) -> String { + let mut label = status.replace('-', " "); + if let Some(first) = label.get_mut(0..1) { + first.make_ascii_uppercase(); + } + label +} + +fn render_verification(output: &mut String, label: &str, coverage: &VerificationCoverage) { + output.push_str(&format!( + "{label}:\n Golden tests: {}\n Functions with golden tests: {} ({:.1}%)\n Edge cases: {}\n Functions with edge cases: {} ({:.1}%)\n", + coverage.golden_tests, + coverage.functions_with_golden_tests, + coverage.functions_with_golden_tests_percentage, + coverage.edge_cases, + coverage.functions_with_edge_cases, + coverage.functions_with_edge_cases_percentage + )); +} + +fn render_values(output: &mut String, values: &[Frequency]) { + if values.is_empty() { + output.push_str(" None declared\n"); + } else { + for value in values { + output.push_str(&format!(" {}: {}\n", value.value, value.count)); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{InputKind, Report}; + use crate::model::{Entry, Spec}; + + fn entry(slug: &str, yaml: &str) -> Entry { + let spec: Spec = serde_yaml::from_str(yaml).expect("fixture specification deserializes"); + let implementations = vec![None; spec.functions.len()]; + Entry { + path: PathBuf::from(format!("specs/functions/{slug}.yaml")), + slug: slug.to_owned(), + spec, + implementations, + } + } + + fn fixture() -> Entry { + entry( + "tester2020", + r##" +source: + summary: Test source. + citation_apa: Tester (2020). + doi: null +scope: {territory: Test source territory., dataset: Test dataset.} +$defs: + x: + name: x + symbol: x + unit: '1' + domain: null + description: Numeric input. + Category: + type: enum + description: Test category. + values: [{name: first, value: first}] + Result: + type: record + name: TestResult + fields: + - {name: zeta, symbol: z, unit: '1', domain: null, description: Zeta.} + - {name: alpha, symbol: a, unit: '1', domain: null, description: Alpha.} +scientific_notes: Blocker evidence from source review. +functions: + - name: calc_ptf_test_implemented + status: implemented + public_api: {name: calc_ptf_test_implemented, summary: Test record.} + scope: + territory: Test function territory. + prediction_target: Target B + models: {h_theta: Model B, k_h: null} + inputs: + - {$ref: '#/$defs/x'} + - {$ref: '#/$defs/x'} + outputs: {$ref: '#/$defs/Result'} + golden_tests: + - {id: one, inputs: {x: 1.0}, expected: {alpha: 1.0, zeta: 2.0}, rtol: 0.0, atol: 0.0} + - {id: two, inputs: {x: 2.0}, expected: {alpha: 2.0, zeta: 3.0}, rtol: 0.0, atol: 0.0} + edge_cases: + - {id: edge, inputs: {x: 0.0}, expected_behavior: Finite., notes: Metadata only.} + - name: calc_ptf_test_blocked + status: blocked + public_api: {name: calc_ptf_test_blocked, summary: Test scalar.} + scope: + prediction_target: Target A + models: {h_theta: null, k_h: Model K} + inputs: + - {$ref: '#/$defs/Category', name: category} + outputs: {type: scalar, name: beta, symbol: b, unit: '1', domain: null, description: Beta.} + golden_tests: [] + edge_cases: [] + documentation: + notes: [Known source limitation.] + warnings: [Required coefficients are unavailable.] + - name: calc_ptf_test_ready + status: ready-for-implementation + public_api: {name: calc_ptf_test_ready, summary: Test scalar.} + scope: + prediction_target: Target A + models: {h_theta: null, k_h: null} + inputs: [{$ref: '#/$defs/x'}] + outputs: {type: scalar, name: gamma, symbol: g, unit: '1', domain: null, description: Gamma.} + - name: calc_ptf_test_draft + status: draft + public_api: {name: calc_ptf_test_draft, summary: Test scalar.} + scope: + prediction_target: Target C + models: {h_theta: null, k_h: null} + inputs: [{$ref: '#/$defs/x'}] + outputs: {type: scalar, name: delta, symbol: d, unit: '1', domain: null, description: Delta.} +"##, + ) + } + + #[test] + fn aggregates_multi_function_status_and_verification_coverage() { + let report = Report::from_entries(&[fixture()]); + + assert_eq!(report.functions.total, 4); + assert!( + report + .functions + .by_status + .iter() + .all(|status| status.count == 1) + ); + assert_eq!(report.verification.all_functions.golden_tests, 2); + assert_eq!(report.verification.all_functions.edge_cases, 1); + assert_eq!( + report + .verification + .all_functions + .functions_with_golden_tests, + 1 + ); + assert_eq!(report.verification.implemented_functions.functions, 1); + assert_eq!( + report + .verification + .implemented_functions + .functions_with_edge_cases_percentage, + 100.0 + ); + } + + #[test] + fn uses_resolved_references_and_deduplicates_inputs_per_function() { + let report = Report::from_entries(&[fixture()]); + let x = report + .inputs + .iter() + .find(|input| input.name == "x") + .expect("resolved x input is reported"); + let category = report + .inputs + .iter() + .find(|input| input.name == "category") + .expect("resolved category input is reported"); + + assert_eq!(x.functions, 3); + assert!(matches!(x.kind, InputKind::Numeric)); + assert!(matches!(category.kind, InputKind::Categorical)); + } + + #[test] + fn reports_scalar_record_outputs_and_sorted_values() { + let report = Report::from_entries(&[fixture()]); + assert_eq!(report.outputs.scalar_functions, 3); + assert_eq!(report.outputs.record_functions, 1); + assert_eq!(report.outputs.field_names[0].value, "alpha"); + assert_eq!(report.scope.prediction_targets[0].value, "Target A"); + } + + #[test] + fn retains_blocked_functions_and_empty_optional_metadata() { + let mut fixture = fixture(); + fixture.spec.scope.territory = None; + fixture.spec.scope.dataset = None; + let report = Report::from_entries(&[fixture]); + + assert_eq!(report.blocked_functions.len(), 1); + assert_eq!( + report.blocked_functions[0].documentation_warnings, + ["Required coefficients are unavailable."] + ); + assert!(report.blocked_functions[0].scientific_notes.is_some()); + assert_eq!( + report + .scope + .calibration_geography + .source_specifications_with_dataset, + 0 + ); + } + + #[test] + fn serializes_deterministically_to_json() { + let report = Report::from_entries(&[fixture()]); + let first = serde_json::to_string_pretty(&report).expect("report serializes"); + let second = serde_json::to_string_pretty(&report).expect("report serializes again"); + + assert_eq!(first, second); + assert!(first.contains("\"blocked_functions\"")); + assert!(first.contains("\"implemented_functions\"")); + } + + #[test] + fn repository_corpus_report_is_complete_and_stable() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root exists"); + let entries = crate::specs::load(root).expect("repository specifications load"); + assert!(crate::validate::specifications(&entries).is_empty()); + crate::compile::functions(entries.clone()).expect("repository specifications compile"); + + let report = Report::from_entries(&entries); + assert_eq!(report.sources.specification_files, entries.len()); + assert_eq!( + report.functions.total, + entries + .iter() + .map(|entry| entry.spec.functions.len()) + .sum::() + ); + assert_eq!( + report + .functions + .by_status + .iter() + .map(|status| status.count) + .sum::(), + report.functions.total + ); + assert_eq!( + serde_json::to_string_pretty(&report).unwrap(), + serde_json::to_string_pretty(&Report::from_entries(&entries)).unwrap() + ); + } +} diff --git a/codegen/src/documentation.rs b/codegen/src/documentation.rs index 3bd9935..3cdbf75 100644 --- a/codegen/src/documentation.rs +++ b/codegen/src/documentation.rs @@ -206,6 +206,7 @@ mod tests { documentation: Documentation::default(), implementation: None, golden_tests: Vec::new(), + edge_cases: Vec::new(), }; let document = for_function(&function); @@ -246,6 +247,7 @@ mod tests { }, implementation: None, golden_tests: Vec::new(), + edge_cases: Vec::new(), }; let document = for_function(&function); diff --git a/codegen/src/main.rs b/codegen/src/main.rs index 304aeef..86022b0 100644 --- a/codegen/src/main.rs +++ b/codegen/src/main.rs @@ -3,9 +3,10 @@ use std::{path::Path, process::ExitCode}; use anyhow::{Context, Result, bail}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; mod compile; +mod corpus_report; mod documentation; mod formula; mod model; @@ -29,7 +30,21 @@ enum Command { Validate, Generate, CheckGenerated, - Version { version: String }, + /// Summarize the validated specification corpus. + CorpusReport { + #[arg(long, value_enum, default_value_t)] + format: ReportFormat, + }, + Version { + version: String, + }, +} + +#[derive(Clone, Copy, Default, ValueEnum)] +enum ReportFormat { + #[default] + Text, + Json, } impl Cli { @@ -59,6 +74,16 @@ impl Cli { let entries = load_validated_specifications(root)?; targets::check_generated(root, entries) } + Command::CorpusReport { format } => { + let entries = load_validated_specifications(root)?; + compile::functions(entries.clone())?; + let report = corpus_report::Report::from_entries(&entries); + match format { + ReportFormat::Text => print!("{}", report.to_text()), + ReportFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?), + } + Ok(()) + } Command::Version { version } => version::run(root, &version), } } diff --git a/codegen/src/model.rs b/codegen/src/model.rs index c5d8213..54f0c4c 100644 --- a/codegen/src/model.rs +++ b/codegen/src/model.rs @@ -9,6 +9,8 @@ pub(crate) struct Spec { pub(crate) scope: Scope, #[serde(default)] pub(crate) generation: Generation, + #[serde(default)] + pub(crate) scientific_notes: String, pub(crate) functions: Vec, } @@ -19,6 +21,8 @@ struct RawSpec { scope: Scope, #[serde(default)] generation: Generation, + #[serde(default)] + scientific_notes: String, #[serde(default, rename = "$defs")] definitions: BTreeMap, functions: Vec, @@ -45,6 +49,8 @@ struct FunctionReference { #[serde(default)] golden_tests: Vec, #[serde(default)] + edge_cases: Vec, + #[serde(default)] documentation: Documentation, } @@ -182,6 +188,7 @@ impl<'de> Deserialize<'de> for Spec { outputs, implementation, golden_tests: function.golden_tests, + edge_cases: function.edge_cases, documentation: function.documentation, }) }) @@ -191,6 +198,7 @@ impl<'de> Deserialize<'de> for Spec { source: raw.source, scope: raw.scope, generation: raw.generation, + scientific_notes: raw.scientific_notes, functions, }) } @@ -369,6 +377,8 @@ pub(crate) struct Function { #[serde(default)] pub(crate) golden_tests: Vec, #[serde(default)] + pub(crate) edge_cases: Vec, + #[serde(default)] pub(crate) documentation: Documentation, } @@ -715,6 +725,7 @@ mod tests { }, scope: Scope::default(), generation: Generation::default(), + scientific_notes: String::new(), functions: Vec::new(), }; diff --git a/codegen/src/targets/python/wrapper.rs b/codegen/src/targets/python/wrapper.rs index da61d46..b77a893 100644 --- a/codegen/src/targets/python/wrapper.rs +++ b/codegen/src/targets/python/wrapper.rs @@ -724,6 +724,7 @@ mod tests { documentation: Documentation::default(), implementation: None, golden_tests: Vec::new(), + edge_cases: Vec::new(), } } diff --git a/docs/src/contributing/development.md b/docs/src/contributing/development.md index cdb5dbf..0afcb73 100644 --- a/docs/src/contributing/development.md +++ b/docs/src/contributing/development.md @@ -126,6 +126,36 @@ The assisted workflow uses the skills in `.agents/skills/`: The source paper is transient input. The reviewed specification is the persisted record and the source of truth for generated implementations. +## Corpus reporting + +The code generator can summarize the current validated specification corpus in +the terminal: + +```sh +mise run corpus-report +``` + +Generate deterministic JSON for CI or publication-oriented tables with: + +```sh +mise run corpus-report --format json +``` + +All counts are derived from `specs/functions/` through the normal loader, +validation, and compilation pipeline. The report intentionally includes +schema-valid `blocked` functions. Its verification coverage describes declared +golden tests and edge cases in the specifications; it is neither predictive +benchmarking nor external validation against soil datasets, and descriptive +edge cases are not claimed to be executable tests. + +Publication years are derived only from a four-digit suffix on the APA-style +source slug because the current schema has no explicit year field. Unresolved +slugs are reported rather than guessed. Prediction targets and hydraulic model +descriptions are reported exactly as structured in the schema; the command does +not infer scientific-property groups from free text. The JSON document uses +stable `sources`, `functions`, `verification`, `inputs`, `outputs`, `scope`, and +`blocked_functions` sections with explicit counts. + ## Checks Use the smallest relevant checks while iterating. Before submitting changes that diff --git a/mise.toml b/mise.toml index 487612f..5edb69e 100644 --- a/mise.toml +++ b/mise.toml @@ -56,3 +56,14 @@ depends = ["codegen:verify", "native:verify", "python:verify", "rust:verify"] [tasks.clean] description = "Remove all local build artifacts and development environments" depends = ["bench:clean", "codegen:clean", "docs:clean", "native:clean", "python:clean", "rust:clean"] + +[tasks.corpus-report] +description = "Summarize the validated specification corpus" +usage = ''' +flag "--format " help="Output format" { + choices "text" "json" + default "text" +} +''' +dir = "codegen" +run = "cargo run corpus-report --format {{ usage.format }}"