From 6d48e549715ec4163af0a7193e93b5a582146be2 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 24 Sep 2026 18:34:40 -0500 Subject: [PATCH] feat(tavi): carry an authored report's own fields as rs: properties An authored report (a RoboLedger tenant's) carries content TAVI has no object for: the reporting style, the entity's legal name, and for each network its structure, block type, fact set and order, and each fact's structure. The holon kept them; the TAVI dropped them. They now ride as model-defined properties under the RoboSystems vocabulary (sections 5.13 / 11.6), each declared by a property type, on the model, entity, network and fact objects, and the reader reads them back, matched on the IRI whatever prefix a document binds. A filing carries none of them, so its document is unchanged: NVDA's and JPM's TAVI are byte-identical to 0.18.0's, with no `rs` binding. The legal name is written only when it differs from the name (the parse sets it to the name), and the report IRI is left out: the report namespace already names the report, and a filing's report_uri is its EDGAR document URL. --- tests/test_deserialize.py | 47 +++++++++++++- xbrlkit/deserialize/tavi.py | 67 +++++++++++++++++-- xbrlkit/serialize/tavi.py | 126 ++++++++++++++++++++++++++++++------ 3 files changed, 213 insertions(+), 27 deletions(-) diff --git a/tests/test_deserialize.py b/tests/test_deserialize.py index ccb9191..6ccd94c 100644 --- a/tests/test_deserialize.py +++ b/tests/test_deserialize.py @@ -49,7 +49,7 @@ from xbrlkit.parse.ids import unit_id from xbrlkit.periods import duration_period, instant_period, period_from_interval from xbrlkit.deserialize.holon import _scheme_for -from xbrlkit.namespaces import ENTITY_SCHEME +from xbrlkit.namespaces import ENTITY_SCHEME, HOLON_VOCAB from xbrlkit.serialize import to_clawdog, to_clawdog_report, to_holon, to_tavi from xbrlkit.serialize._values import CIK_SCHEME from xbrlkit.serve import tools @@ -601,6 +601,51 @@ def test_round_trip_keeps_the_facts(model: XbrlModel, fmt: str) -> None: assert cash.unit_id == next(u.id for u in got.units if u.measure == "iso4217:USD") +def test_tavi_round_trips_an_authored_reports_own_fields(model: XbrlModel) -> None: + """An authored report's style, legal name, and each network's structure, + block type, fact set and order, ride as `rs:` properties and come back.""" + model.filing.reporting_style = "BSC-CORP-IS02-CF1" + model.entity.name = "Acme" + model.entity.legal_name = "Acme Holdings, Inc." + for network in model.networks: + if network.role_uri == BALANCE_SHEET: + network.block_type = "balance_sheet" + network.structure_id = "struct_01" + network.fact_set_id = "fs_01" + network.structure_order = 100 + for fact in model.facts: + fact.structure_id = "struct_01" + text = to_tavi(model) + assert json.loads(text)["documentInfo"]["namespaces"]["rs"] == HOLON_VOCAB + + got, gaps = from_tavi_report(text) + assert got.filing.reporting_style == "BSC-CORP-IS02-CF1" + assert got.entity.legal_name == "Acme Holdings, Inc." + presentation = next( + n for n in got.networks if n.role_uri == BALANCE_SHEET and n.kind == "presentation" + ) + assert ( + presentation.block_type, + presentation.structure_id, + presentation.fact_set_id, + presentation.structure_order, + ) == ("balance_sheet", "struct_01", "fs_01", 100) + assert all(f.structure_id == "struct_01" for f in got.facts) + assert gaps.unmapped_fact_properties == {} + + +def test_a_filings_tavi_carries_no_report_properties(model: XbrlModel) -> None: + """A filing has none of those fields, so its document is spec-shaped only: + no `rs` binding, no property types. A legal name equal to the name, which + the parse always sets, writes nothing.""" + model.entity.legal_name = model.entity.name + model.filing.report_uri = "https://www.sec.gov/Archives/edgar/data/1/0001-24-1.htm" + document = json.loads(to_tavi(model)) + assert "rs" not in document["documentInfo"]["namespaces"] + assert "propertyTypes" not in document["xbrlModel"] + assert "rs:" not in json.dumps(document) + + def test_tavi_reads_an_exact_fact_as_infinitely_precise(model: XbrlModel) -> None: """TAVI writes INF by leaving `decimals` out; the reader must not read that as unknown, or an exact fact loses to a rounded duplicate of itself.""" diff --git a/xbrlkit/deserialize/tavi.py b/xbrlkit/deserialize/tavi.py index 9d842e6..aea87ca 100644 --- a/xbrlkit/deserialize/tavi.py +++ b/xbrlkit/deserialize/tavi.py @@ -57,7 +57,7 @@ XbrlModel, ) from ..namespaces import HOLON_VOCAB, PROV_VOCAB, TAVI_REPORT_BASE -from ..serialize.tavi import PROVENANCE_PROPERTIES, REPORT_PREFIX +from ..serialize.tavi import PROVENANCE_PROPERTIES, REPORT_PREFIX, REPORT_PROPERTIES from ..parse.ids import unit_id from ..periods import period_from_interval from ..serialize._values import CIK_SCHEME @@ -213,7 +213,7 @@ def _read(document: Mapping[str, Any]) -> tuple[XbrlModel, ImportGaps]: entity = _entity(xbrl_model, namespaces) concepts = _concepts(xbrl_model, namespaces, gaps) _apply_labels(xbrl_model, concepts, entity, _entity_sqname(xbrl_model), gaps) - networks = _networks(xbrl_model) + networks = _networks(xbrl_model, namespaces) networks.extend(_definition_networks(xbrl_model, networks, concepts)) facts, periods, units = _facts(xbrl_model, concepts, entity, namespaces, gaps) _mark_text_facts(concepts, facts) @@ -251,7 +251,17 @@ def _entity( if not identifier: prefix, identifier = "", name scheme = namespaces.get(prefix) or (CIK_SCHEME if prefix == "cik" else prefix) - return EntityIdentity(cik=identifier or "", scheme=scheme or CIK_SCHEME) + entities = _sequence(xbrl_model.get("entities")) + legal_name = ( + _report_property(_mapping(entities[0]), "rs:legalName", namespaces) + if entities + else None + ) + return EntityIdentity( + cik=identifier or "", + scheme=scheme or CIK_SCHEME, + legal_name=None if legal_name is None else str(legal_name), + ) def _filing( @@ -282,8 +292,10 @@ def _filing( fiscal_end = cover.get(_DEI_FISCAL_YEAR_END, "") if entity.name is None: entity.name = cover.get(_DEI_REGISTRANT_NAME) + reporting_style = _report_property(xbrl_model, "rs:reportingStyle", namespaces) return FilingMeta( accession=accession or "unknown", + reporting_style=None if reporting_style is None else str(reporting_style), cik=entity.cik, form=cover.get(_DEI_DOCUMENT_TYPE), is_inline_xbrl=None, @@ -550,7 +562,9 @@ def _roles(xbrl_model: Mapping[str, Any]) -> _Roles: return _Roles(network_roles, definitions, documentations) -def _networks(xbrl_model: Mapping[str, Any]) -> list[Network]: +def _networks( + xbrl_model: Mapping[str, Any], namespaces: Mapping[str, str] +) -> list[Network]: """Networks, rejoined to the extended link roles their groups stand for.""" roles = _roles(xbrl_model) networks: list[Network] = [] @@ -578,11 +592,43 @@ def _networks(xbrl_model: Mapping[str, Any]) -> list[Network]: documentation=roles.documentations.get(role_uri), kind=kind, arcs=_arcs(_sequence(obj.get("relationships")), kind), + block_type=_optional_str(_report_property(obj, "rs:blockType", namespaces)), + structure_id=_optional_str(_report_property(obj, "rs:structureId", namespaces)), + fact_set_id=_optional_str(_report_property(obj, "rs:factSetId", namespaces)), + structure_order=_optional_int( + _report_property(obj, "rs:structureOrder", namespaces) + ), ) ) return networks +def _report_property( + obj: Mapping[str, Any], qname: str, namespaces: Mapping[str, str] +) -> Any: + """The value an object's ``properties`` give one report property, if any.""" + for entry in _sequence(obj.get("properties")): + prop = _mapping(entry) + iri = _expand(str(prop.get("property", "")), namespaces) + if _REPORT_PROPERTY_IRIS.get(iri) == qname: + value = prop.get("value") + if isinstance(value, list) and len(value) == 1: + value = value[0] + return value + return None + + +def _optional_str(value: Any) -> str | None: + return None if value is None else str(value) + + +def _optional_int(value: Any) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + # The dimensions every reconstructed cube declares that are not taxonomy axes. _CORE_DIMENSIONS = frozenset(("xbrl:concept", *OPTIONAL_CORE_DIMENSIONS)) @@ -856,6 +902,7 @@ def _facts( is_nil=not values, language=str(language) if isinstance(language, str) else None, provenance=_fact_provenance(obj, namespaces, gaps), + structure_id=_optional_str(_report_property(obj, "rs:structureId", namespaces)), ) ) @@ -894,6 +941,13 @@ def _expand(qname: str, namespaces: Mapping[str, str]) -> str: return f"{uri}{local}" if uri.endswith(("#", "/")) else f"{uri}#{local}" +# Expanded IRI of each report property, so a document is matched on the IRI +# whatever prefix it bound. +_REPORT_PROPERTY_IRIS: dict[str, str] = { + _expand(qname, {"rs": HOLON_VOCAB}): qname for qname, _, _ in REPORT_PROPERTIES +} + + # Expanded property IRI -> ``FactProvenance`` field, for the four provenance # properties: a document is matched on the IRI, whatever prefix it bound. _PROVENANCE_IRIS: dict[str, str] = { @@ -913,7 +967,10 @@ def _fact_provenance( value = prop.get("value") if isinstance(value, list) and len(value) == 1: value = value[0] - attr = _PROVENANCE_IRIS.get(_expand(qname, namespaces)) + iri = _expand(qname, namespaces) + if iri in _REPORT_PROPERTY_IRIS: + continue # read where the fact is built + attr = _PROVENANCE_IRIS.get(iri) if attr is None or value is None: if qname: gaps.unmapped_fact_properties[qname] = ( diff --git a/xbrlkit/serialize/tavi.py b/xbrlkit/serialize/tavi.py index 3ca83eb..0f77a4c 100644 --- a/xbrlkit/serialize/tavi.py +++ b/xbrlkit/serialize/tavi.py @@ -745,6 +745,8 @@ def _namespaces(model: XbrlModel, report_id: str) -> dict[str, str]: if any(fact.provenance is not None for fact in model.facts): namespaces["prov"] = PROV_VOCAB namespaces["rs"] = HOLON_VOCAB + if _report_properties_used(model): + namespaces["rs"] = HOLON_VOCAB by_uri = {uri: prefix for prefix, uri in namespaces.items()} for concept in model.concepts.values(): @@ -781,6 +783,10 @@ def _model_properties(model: XbrlModel) -> list[dict[str, object]]: "value": model.filing.filing_date.isoformat(), } ) + if model.filing.reporting_style: + properties.append( + {"property": "rs:reportingStyle", "value": model.filing.reporting_style} + ) return properties @@ -808,7 +814,22 @@ def _entities(model: XbrlModel) -> list[dict[str, object]]: under this report's own namespace, which dropped the scheme and gave two converters of one filing two different entities. """ - return [{"name": entity_sqname(model.entity)}] + entity: dict[str, object] = {"name": entity_sqname(model.entity)} + legal_name = _distinct_legal_name(model) + if legal_name: + entity["properties"] = [{"property": "rs:legalName", "value": legal_name}] + return [entity] + + +def _distinct_legal_name(model: XbrlModel) -> str | None: + """The legal name, when it says something the entity's name label does not. + + The parse sets the legal name to the name when the filing gives only one; + writing that copy would put our vocabulary into every filing's document, + so only a legal name that differs from the name is written. + """ + legal_name = model.entity.legal_name + return legal_name if legal_name and legal_name != model.entity.name else None def _entity_labels(model: XbrlModel, default_language: str) -> list[dict[str, object]]: @@ -1111,13 +1132,15 @@ def group_for(role_uri: str) -> str: for position, root in enumerate(roots, start=1) ] relationships.extend(_relationship(arc, network) for arc in network.arcs) - networks.append( - { - "name": name, - "relationshipTypeName": relationship_type, - "relationships": relationships, - } - ) + network_object: dict[str, object] = { + "name": name, + "relationshipTypeName": relationship_type, + "relationships": relationships, + } + properties = _network_properties(network) + if properties: + network_object["properties"] = properties + networks.append(network_object) group_contents.append({"groupName": group_for(network.role_uri), "forObject": name}) # A cube joins the section whose definition linkbase declared its hypercube @@ -1219,10 +1242,13 @@ def _facts( if decimals is not None: fact_value["decimals"] = decimals entry["factValues"] = [fact_value] - if fact.provenance is not None: - properties = _provenance_properties(fact.provenance) - if properties: - entry["properties"] = properties + properties = ( + _provenance_properties(fact.provenance) if fact.provenance is not None else [] + ) + if fact.structure_id: + properties.append({"property": "rs:structureId", "value": fact.structure_id}) + if properties: + entry["properties"] = properties facts.append(entry) return facts @@ -1271,25 +1297,83 @@ def _fact_name(fact: XbrlFact, index: int, names: set[str], gaps: GapReport) -> ) +# What an authored report carries that TAVI has no object for, as model-defined +# properties under the RoboSystems vocabulary (sections 5.13 / 11.6): the +# report's style, the entity's legal name, each network's structure, block +# type, fact set and order, and each fact's structure. A filing carries none +# of them, so its document declares and binds nothing new. The report's own IRI +# is not among them: the report namespace already names the report, and a +# filing's `report_uri` (its EDGAR document) would otherwise be written too. +REPORT_PROPERTIES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("rs:reportingStyle", "xs:string", ("xbrl:xbrlModelObject",)), + ("rs:legalName", "xs:string", ("xbrl:entityObject",)), + ("rs:blockType", "xs:string", ("xbrl:networkObject",)), + ("rs:structureId", "xs:string", ("xbrl:networkObject", "xbrl:factObject")), + ("rs:factSetId", "xs:string", ("xbrl:networkObject",)), + ("rs:structureOrder", "xs:integer", ("xbrl:networkObject",)), +) + + +def _network_properties(network: Network) -> list[dict[str, object]]: + properties: list[dict[str, object]] = [] + for qname, value in ( + ("rs:blockType", network.block_type), + ("rs:structureId", network.structure_id), + ("rs:factSetId", network.fact_set_id), + ("rs:structureOrder", network.structure_order), + ): + if value is not None and value != "": + properties.append({"property": qname, "value": value}) + return properties + + +def _report_properties_used(model: XbrlModel) -> set[str]: + """The report properties this model has a value for.""" + used: set[str] = set() + if model.filing.reporting_style: + used.add("rs:reportingStyle") + if _distinct_legal_name(model): + used.add("rs:legalName") + for network in model.networks: + if network.kind != "definition": + used.update(str(entry["property"]) for entry in _network_properties(network)) + if any(fact.structure_id for fact in model.facts): + used.add("rs:structureId") + return used + + def _property_types(model: XbrlModel) -> list[dict[str, object]]: - """The property type objects (section 11.6) the facts' provenance needs. + """The property type objects (section 11.6) the model's properties need. - Declared only when a fact carries provenance: section 3.1 forbids a + Declared only when something carries the property: section 3.1 forbids a property no type declares, and the type is what makes the property mean the same thing to the next reader. ``definitional`` is false — where a fact - came from is contextual metadata, not part of its identity (11.6.1). + came from, or which structure a network renders, is contextual metadata, + not part of the object's identity (11.6.1). """ - if not any(fact.provenance is not None for fact in model.facts): - return [] - return [ + types: list[dict[str, object]] = [] + if any(fact.provenance is not None for fact in model.facts): + types.extend( + { + "name": qname, + "dataType": datatype, + "definitional": False, + "allowedObjects": ["xbrl:factObject"], + } + for _, qname, datatype in PROVENANCE_PROPERTIES + ) + used = _report_properties_used(model) + types.extend( { "name": qname, "dataType": datatype, "definitional": False, - "allowedObjects": ["xbrl:factObject"], + "allowedObjects": list(allowed), } - for _, qname, datatype in PROVENANCE_PROPERTIES - ] + for qname, datatype, allowed in REPORT_PROPERTIES + if qname in used + ) + return types def _provenance_properties(provenance: FactProvenance) -> list[dict[str, object]]: