use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::path::Path;
use serde::Deserialize;
use super::builder::{EntityDef, OntologyBuilder};
#[derive(Debug)]
pub enum ParseError {
Read(String, std::io::Error),
Json(serde_json::Error),
}
impl core::fmt::Display for ParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Read(path, e) => write!(f, "read {path}: {e}"),
Self::Json(e) => write!(f, "parse JSON: {e}"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse_statute_json(path: &Path) -> Result<OntologyBuilder, ParseError> {
let raw = std::fs::read_to_string(path)
.map_err(|e| ParseError::Read(path.display().to_string(), e))?;
let doc: RawStatuteDoc = serde_json::from_str(&raw).map_err(ParseError::Json)?;
Ok(build_from_doc(&doc))
}
pub fn build_from_doc(doc: &RawStatuteDoc) -> OntologyBuilder {
let mut b = OntologyBuilder::new();
for term in &doc.terms {
let mut ent = EntityDef::new(&term.id, &term.name);
ent = ent.pos("statute_term");
ent = ent.definition(&term.definition);
b.add_entity(ent);
for lemma in &term.lemmas {
b.add_word_index(lemma, &term.id);
}
}
for rel in &doc.relations {
let from = rel.from.as_str();
let to = rel.to.as_str();
match &rel.relation {
RawRel::SubtypeOf => {
b.add_taxonomy(from, to);
}
RawRel::Requires | RawRel::Composes { .. } | RawRel::SafeHarborFor => {
b.add_mereology(from, to);
}
RawRel::Contradicts
| RawRel::Negates
| RawRel::Rebuts { .. }
| RawRel::AffirmativeDefenseTo => {
b.add_opposition(from, to);
}
RawRel::Implies { .. }
| RawRel::Triggers { .. }
| RawRel::Precedes { .. }
| RawRel::ExhaustionRequiredFor => {
b.add_causation(from, to);
}
RawRel::AlternativeTo => {
b.add_equivalence(from, to);
}
}
}
b
}
#[derive(Debug, Default, Deserialize)]
pub struct RawStatuteDoc {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub terms: Vec<RawTerm>,
#[serde(default)]
pub relations: Vec<RawRelation>,
}
#[derive(Debug, Deserialize)]
pub struct RawTerm {
pub id: String,
pub name: String,
#[serde(default)]
pub definition: String,
#[serde(default)]
pub lemmas: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawRelation {
pub from: String,
pub to: String,
pub relation: RawRel,
}
#[derive(Debug, Deserialize)]
pub enum RawRel {
Requires,
SubtypeOf,
Contradicts,
Negates,
AlternativeTo,
AffirmativeDefenseTo,
SafeHarborFor,
ExhaustionRequiredFor,
Precedes {
#[allow(dead_code)]
max_days: Option<u32>,
},
Implies {
#[allow(dead_code)]
consequence: Option<String>,
},
Composes {
#[allow(dead_code)]
into: Option<String>,
},
Triggers {
#[allow(dead_code)]
obligation: Option<String>,
},
Rebuts {
#[allow(dead_code)]
burden: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_JSON: &str = r#"{
"name": "test_statute",
"description": "Synthetic test statute.",
"terms": [
{
"id": "test:a",
"name": "Protected Activity",
"definition": "Activity protected from retaliation.",
"lemmas": ["report", "disclose"]
},
{
"id": "test:a_v2",
"name": "Adverse Action",
"definition": "Discharge, demotion, or harassment.",
"lemmas": ["discharge", "demote"]
},
{
"id": "test:a_v3",
"name": "Causation",
"definition": "Because-of nexus.",
"lemmas": []
},
{
"id": "test:claim",
"name": "Prima Facie Claim",
"definition": "The composed claim.",
"lemmas": []
}
],
"relations": [
{ "from": "test:a", "to": "test:claim",
"relation": { "Composes": { "into": "claim" } } },
{ "from": "test:a_v2", "to": "test:claim",
"relation": { "Composes": { "into": "claim" } } },
{ "from": "test:a_v3", "to": "test:claim",
"relation": { "Composes": { "into": "claim" } } },
{ "from": "test:a", "to": "test:a_v2",
"relation": "Contradicts" }
]
}"#;
#[crate::praxis_value(Verifiable)]
#[test]
fn parses_terms_into_entities() {
let doc: RawStatuteDoc = serde_json::from_str(SAMPLE_JSON).unwrap();
let b = build_from_doc(&doc);
assert_eq!(b.entities.len(), 4);
assert!(b.entities.iter().any(|e| e.id == "test:claim"));
assert!(
b.entities
.iter()
.find(|e| e.id == "test:a")
.map(|e| e.lemmas.is_empty())
.unwrap_or(true)
);
assert!(b.word_index.iter().any(|(w, _)| w == "report"));
assert!(b.word_index.iter().any(|(w, _)| w == "discharge"));
}
#[crate::praxis_value(Verifiable)]
#[test]
fn composes_relation_maps_to_mereology() {
let doc: RawStatuteDoc = serde_json::from_str(SAMPLE_JSON).unwrap();
let b = build_from_doc(&doc);
assert_eq!(b.mereology.len(), 3);
assert!(
b.mereology
.iter()
.any(|(a, b)| a == "test:a" && b == "test:claim")
);
}
#[crate::praxis_value(Verifiable)]
#[test]
fn contradicts_relation_maps_to_opposition() {
let doc: RawStatuteDoc = serde_json::from_str(SAMPLE_JSON).unwrap();
let b = build_from_doc(&doc);
assert_eq!(b.opposition.len(), 1);
assert!(
b.opposition
.iter()
.any(|(a, b)| a == "test:a" && b == "test:a_v2")
);
}
#[crate::praxis_value(Verifiable)]
#[test]
fn relation_count_aggregates() {
let doc: RawStatuteDoc = serde_json::from_str(SAMPLE_JSON).unwrap();
let b = build_from_doc(&doc);
assert_eq!(b.relation_count(), 4);
}
#[crate::praxis_value(Verifiable)]
#[test]
fn unknown_fields_are_ignored() {
let json = r#"{
"name": "test",
"description": "x",
"authority": {"Constitution": {"provision": "see source"}},
"terms": [{
"id": "x:1",
"name": "X",
"definition": "x def",
"valence": "Supportive",
"subsection": "(a)",
"required_evidence": [],
"obligations": [],
"deadlines": []
}],
"relations": []
}"#;
let doc: RawStatuteDoc = serde_json::from_str(json).unwrap();
let b = build_from_doc(&doc);
assert_eq!(b.entities.len(), 1);
assert_eq!(b.entities[0].id, "x:1");
}
}