Skip to content

fix(export): harden YAML export input handling - #958

Merged
KaifAhmad1 merged 12 commits into
semantica-agi:mainfrom
pravit-amp:fix/yaml-export-input-validation
Aug 15, 2026
Merged

fix(export): harden YAML export input handling#958
KaifAhmad1 merged 12 commits into
semantica-agi:mainfrom
pravit-amp:fix/yaml-export-input-validation

Conversation

@pravit-amp

Copy link
Copy Markdown
Contributor

Description

Consolidates the YAML-export input-handling work into one PR. Supersedes #954, which carried the first commit alone.

The three changes are one story and are hard to review apart: #953's fix is written against the resolver added for #956, and #952 and #953 reject adjacent shapes of the same bad input through the same code path. All three issues are assigned to me.

Today, three different malformed inputs to export_yaml fail three different ways — one loud but uninformative, one silent, one correct only by accident:

export_yaml(records, "out.yaml")             # AttributeError from deep inside the exporter
export_yaml({"data": records}, "out.yaml")   # succeeds, writes a file with every record gone
export_yaml(graph.to_dict(), "out.yaml")     # succeeds, writes an empty file (nodes/edges)

After this PR the first two raise with an actionable message, and the third exports its records.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance improvement
  • Code refactoring

Related Issues

Closes #956
Closes #952
Closes #953

Changes Made

  • normalize_graph_payload() in utils.helpers ([FEATURE] refactor(export): centralize graph-payload key normalization — four competing idioms across nine exporters produce divergent results #956) — graph payloads circulate under two vocabularies, entities/relationships and nodes/edges, and consumers each reconciled them locally with competing idioms, so the same payload could be exported, silently dropped, or rejected depending on which consumer read it. This becomes the single place that decision is made, adopted by LPGExporter, ArangoAQLExporter and Neo4jCSVExporter. LPGExporter in particular dropped every entity when nodes was present but empty — the shape JSONExporter emits.
  • Non-mapping input rejected by the YAML exporters ([BUG] export_yaml raises AttributeError on List[Dict], a type its own signature declares as supported #952) — both read their payload by key, so a list surfaced as a bare AttributeError naming neither the argument nor the expected shape. Rejected rather than wrapped: these formats distinguish entities from relationships from triplets, so guessing which one a list holds would mislabel the records.
  • Unrecognized mappings rejected ([BUG] export_yaml silently writes an empty export when the input dict lacks its expected keys — no warning, logs report success #953) — a mapping keyed by anything the exporters do not read serialized to a structurally valid file with every collection empty, with no exception, no warning, and the progress log reporting a completed export. export_semantic_network, export_for_pipeline and export_ontology_schema now validate first.
  • ContextGraph.to_dict() exports correctly — the nodes/edges aliases come with the shared resolver, so the most direct path from this library's own graph type to YAML (used in examples/capability_gap_context_graphs_example.py) no longer produces an empty file.
  • export() serializes before creating the output directory, so a rejected export leaves nothing behind.

Design notes for reviewers

  • Two exception types, deliberately. A payload of the wrong type cannot be exported at all and raises ProcessingError, matching Neo4jCSVExporter._normalize_graph. A mapping whose contents are unusable raises ValidationError, matching normalize_graph_payload. _require_mapping runs first at every entry point, so a non-mapping never reaches the resolver. Happy to flatten to one type if you'd prefer.
  • metadata alone does not make a payload recognized. An export_json envelope carries one, so accepting it would readmit the exact silent-empty export this is most likely needed for.
  • An empty mapping still exports. An empty graph is legitimate and has no records to lose.
  • Two non-empty spellings of one collection are refused rather than guessed at — there is no basis for preferring either, and picking one would silently discard the other.
  • CSVExporter and JSONExporter are deliberately excluded from the resolver: they write entities, relationships, nodes and edges as separate outputs by design rather than reconciling two spellings of one collection, so normalizing there would rename output files.

Testing

  • Tested locally
  • Added tests for new functionality
  • Package builds successfully (python -m build)

New coverage:

  • tests/utils/test_normalize_graph_payload.py — resolver behaviour, plus end-to-end assertions run through the real exporters rather than mocks, since the behaviour under test is that the exporters now agree.
  • tests/export/test_yaml_exporter_input_validation.py — non-mapping rejection, both directions, including that no file is written.
  • tests/export/test_yaml_exporter_key_recognition.py — every input shape from [BUG] export_yaml silently writes an empty export when the input dict lacks its expected keys — no warning, logs report success #953's table, a ContextGraph.to_dict() round-trip built from a real graph so it breaks if to_dict() changes vocabulary, and an assertion that no success is logged for a rejected export.

Test Commands

pytest tests/export tests/utils tests/context tests/test_export_module.py tests/test_export_methods_wrapper.py tests/test_notebooks_simulation.py
# 641 passed, 62 subtests passed

python -m build
# Successfully built semantica-0.6.5.tar.gz and semantica-0.6.5-py3-none-any.whl

black, isort and flake8 are clean on every line this PR touches. (black still reports pre-existing trailing whitespace in unrelated docstrings of utils/helpers.py and export/methods.py, left alone to keep the diff scoped.)

Documentation

  • Updated relevant documentation
  • Added code examples if applicable
  • Updated API reference if adding new APIs
  • Updated cookbook if adding new examples
  • No documentation changes needed

semantica/export/export_usage.md gains an "Accepted Input" section with the recognized key sets per method; docs/reference/export.md notes the contract inline. Every public method touched has its Raises: section updated. No cookbook changes — no new examples were added.

Breaking Changes

Breaking Changes: Yes

Three call patterns that previously returned cleanly now raise:

Call Before After
export_yaml({"data": [...]}, path) wrote a file with every record dropped ValidationError
export_yaml({"nodes": [...]}, path, method="schema") wrote empty classes/properties ValidationError
export_yaml([...], path) AttributeError from inside the exporter ProcessingError

Migration: pass the records under a recognized key — {"entities": [...]} / {"nodes": [...]} for semantic_network, {"classes": [...]} for schema.

This was raised on #953 before implementation, and raising was chosen over warning: the current logs affirmatively report success, so a caller has no way to detect the loss. Anyone whose call now raises was writing an empty file and losing data. {} still exports, so genuinely-empty graphs are unaffected. A nodes/edges payload that previously wrote an empty file now writes its records — a behaviour change, but in the direction the caller intended.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • Package builds successfully

Additional Notes

Assignment: #953 was assigned by @KaifAhmad1 on the issue; #952 and #956 are also assigned to me.

Reviewable commit-by-commit — the four commits are ordered refactor → #952#952 review fixes → #953, each self-contained.

One follow-up deliberately left out of scope: CSVExporter and JSONExporter still emit both vocabularies as separate outputs. That is by design today, but worth a look if the project wants one canonical on-disk shape.

Pravit Ampapathini added 4 commits August 12, 2026 13:55
Graph payloads circulate under two vocabularies, entities/relationships and nodes/edges, and consumers each reconciled them locally with competing idioms. The same payload could be exported, silently dropped, or rejected depending on which consumer read it.

Add normalize_graph_payload() to utils.helpers as the single place that decision is made. Both spellings present with one empty resolves to the populated one, which is the shape JSONExporter emits; both non-empty and different is refused, since there is no basis to prefer either and picking one would silently discard the other; a non-empty mapping with no recognized key raises rather than returning empty collections, with require_recognized=False for callers that should degrade.

Adopt it in the three exporters that genuinely alias. LPGExporter read nodes with entities as the default, so it dropped every entity when nodes was present but empty, losing everything on a JSON round-trip. ArangoAQLExporter had the same idiom plus a manual fallback. Neo4jCSVExporter routes its mapping branch through the shared resolver so the reference implementation cannot drift; its attribute branch stays local, since objects are not mappings.

Also feed LPGExporter._generate_indexes the resolved entities. It read entities directly, so a nodes/edges payload produced no indexes even once node generation was fixed.

CSVExporter and JSONExporter are deliberately excluded: they write entities, relationships, nodes and edges as separate outputs by design rather than reconciling two spellings of one collection, so normalizing there would rename output files.
export_yaml declared Union[Dict[str, Any], List[Dict[str, Any]]], but both
YAML exporters read their payload by key, so a list reached .get() and
surfaced as a bare AttributeError from inside the exporter, naming neither
the offending argument nor the shape expected.

Reject rather than wrap. These formats distinguish entities from
relationships from triplets, so inferring which collection a bare list
represents would silently mislabel the records, and wrapping it under an
unrecognised key would write a structurally valid file with every
collection empty - trading a loud failure for silent data loss.

Validate in the exporters, matching the existing precedent in
Neo4jCSVExporter._normalize_graph, so direct users of the classes get the
same contract as callers of the convenience wrapper. Narrow the wrapper
type hint to Dict[str, Any] to match.
- semantica/export/yaml_exporter.py — import Sequence from typing
  instead of collections.abc. `Sequence[str]` in _require_mapping's
  annotation is evaluated at function-definition time; collections.abc.Sequence
  only became subscriptable in Python 3.9, so on the 3.8 this project
  declares support for, importing this module raised TypeError.
  typing.Sequence has supported subscripting since 3.5.3. Mapping stays
  imported from collections.abc since it's only used for isinstance.
- tests/export/test_yaml_exporter_input_validation.py — clean up each
  test's tempfile.mkdtemp() dir via addCleanup instead of leaking it,
  and read exported YAML through a context manager instead of an
  unclosed yaml.safe_load(open(...)).
Both YAML exporters built their output from a fixed set of `.get(key, [])`
lookups, so a mapping keyed by anything else serialized to a structurally
valid file with every collection empty. Nothing signalled the loss: no
exception, no warning, and the progress log reported a completed export.
The only way to notice was to open the file. The realistic trigger is
re-exporting an `export_json` payload, whose `{"data", "count", "metadata"}`
envelope drops every record.

- SemanticNetworkYAMLExporter.export_semantic_network now resolves its
  collections through normalize_graph_payload(), which raises rather than
  returning empty collections for an unrecognized mapping. Adopting the
  shared resolver rather than repeating the check locally also brings the
  'nodes'/'edges' aliases, so ContextGraph.to_dict() — the most direct path
  from this library's own graph type to YAML, used in
  examples/capability_gap_context_graphs_example.py — exports its records
  instead of an empty file.
- export_for_pipeline built its nested semantic network from the same
  defaulted lookups and had the same defect; it goes through the resolver
  too.
- YAMLSchemaExporter.export_ontology_schema gets the equivalent check over
  its own key set. Schemas are a separate vocabulary with no aliasing, so
  _require_recognized_keys lives in this module rather than in the shared
  graph resolver.
- 'metadata' is deliberately not sufficient to make a payload recognized.
  An export_json envelope carries one, so accepting it would readmit the
  case this fix is most likely to be needed for.
- An empty mapping is still exported: an empty graph is legitimate and has
  no records to lose.
- SemanticNetworkYAMLExporter.export() serializes before creating the
  output directory, so a rejected export leaves nothing behind.

The two rejections keep distinct exception types, following what the
codebase already does: a payload of the wrong *type* cannot be exported at
all and raises ProcessingError, matching Neo4jCSVExporter._normalize_graph;
a mapping whose *contents* are unusable raises ValidationError, matching
normalize_graph_payload. _require_mapping therefore runs first at every
entry point, so a non-mapping never reaches the resolver.

Docstring Raises sections, export_usage.md and docs/reference/export.md
record the accepted input shapes and both failures.

Closes semantica-agi#953.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Harden YAML export validation and normalize graph-payload vocabularies

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Centralize nodes/edges vs entities/relationships normalization across graph exporters.
• Validate YAML export inputs to prevent silent empty exports and unclear AttributeErrors.
• Add docs and regression tests for accepted shapes, round-trips, and no-file-on-failure.
Diagram

graph TD
  A["export_yaml()"] --> B["YAML exporters"] --> C["normalize_graph_payload()"] --> D["Output files"]
  E["LPGExporter"] --> C --> D
  F["Arango AQL"] --> C --> D
  G["Neo4j CSV"] --> C --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Infer list meaning for YAML (auto-wrap records)
  • ➕ Keeps legacy list-based call sites working without raising
  • ➖ Ambiguous: cannot reliably decide entities vs relationships vs triplets
  • ➖ Risks mislabeling data silently (worse than failing fast)
2. Warn-and-export-empty for unrecognized mappings
  • ➕ Non-breaking behavior for callers currently passing wrong keys
  • ➖ Continues silent data loss risk; logs are easy to miss in batch workflows
  • ➖ Callers still receive a ‘successful’ artifact that is semantically wrong
3. Canonicalize upstream producers (single vocabulary everywhere)
  • ➕ Eliminates the need for a resolver long-term
  • ➕ Simplifies exporter code paths
  • ➖ Larger, more disruptive refactor across graph builders/exporters
  • ➖ Harder to coordinate/roll out; still need compatibility during transition

Recommendation: Keep the PR’s approach: a single shared resolver + explicit type/key validation with clear exceptions. It minimizes ambiguity, prevents silent data loss, and ensures all exporters interpret the same payload shape consistently while still supporting nodes/edges as aliases.

Files changed (12) +985 / -42

Bug fix (4) +207 / -39
arango_aql_exporter.pyUse shared graph payload normalizer for AQL entity/relationship extraction +6/-12

Use shared graph payload normalizer for AQL entity/relationship extraction

• Replaces local entities/nodes and relationships/edges reconciliation logic with normalize_graph_payload(). Ensures Arango export interprets mixed-vocabulary payloads consistently with other exporters.

semantica/export/arango_aql_exporter.py

lpg_exporter.pyNormalize nodes/edges vs entities/relationships and fix index generation inputs +20/-12

Normalize nodes/edges vs entities/relationships and fix index generation inputs

• Routes LPG export through normalize_graph_payload() to avoid dropping entities when nodes is present but empty. Updates index generation to take the resolved entity list so index creation matches the exported node set.

semantica/export/lpg_exporter.py

methods.pyTighten export_yaml signature and document new error behavior +20/-2

Tighten export_yaml signature and document new error behavior

• Changes export_yaml() type hints to require a mapping payload and expands docstring to explain why lists are rejected. Documents ProcessingError vs ValidationError behaviors for wrong-type and wrong-key payloads.

semantica/export/methods.py

yaml_exporter.pyFail fast on invalid YAML export inputs and unify graph key resolution +161/-13

Fail fast on invalid YAML export inputs and unify graph key resolution

• Adds _require_mapping() to raise ProcessingError with actionable messages for non-mapping payloads. Adds _require_recognized_keys() for schema exports and routes semantic-network exports through normalize_graph_payload(), plus reorders export() to serialize before creating output directories.

semantica/export/yaml_exporter.py

Refactor (3) +122 / -3
neo4j_csv_exporter.pyDelegate dict payload normalization to shared resolver +7/-3

Delegate dict payload normalization to shared resolver

• Updates _normalize_graph() so mapping payloads are resolved via normalize_graph_payload(require_recognized=False), preventing drift from other exporters. Keeps the attribute-based object path local.

semantica/export/neo4j_csv_exporter.py

__init__.pyExport normalize_graph_payload from semantica.utils +2/-0

Export normalize_graph_payload from semantica.utils

• Adds normalize_graph_payload to the public utils re-exports to support external use and consistent internal imports.

semantica/utils/init.py

helpers.pyIntroduce normalize_graph_payload() for canonical graph payload vocabulary +113/-0

Introduce normalize_graph_payload() for canonical graph payload vocabulary

• Adds a centralized resolver that canonicalizes entities/relationships/triplets while accepting nodes/edges as aliases. Rejects conflicting dual spellings and (by default) rejects non-empty unrecognized mappings to prevent silent record loss.

semantica/utils/helpers.py

Tests (3) +617 / -0
test_yaml_exporter_input_validation.pyRegression tests for rejecting non-mapping YAML export inputs (#952) +181/-0

Regression tests for rejecting non-mapping YAML export inputs (#952)

• Adds tests ensuring non-mapping inputs raise ProcessingError with actionable messages across both YAML methods. Verifies no output file is created on rejection and that valid mappings (including Mapping subclasses) still export.

tests/export/test_yaml_exporter_input_validation.py

test_yaml_exporter_key_recognition.pyRegression tests for rejecting wrong-key YAML payloads and preserving records (#953) +268/-0

Regression tests for rejecting wrong-key YAML payloads and preserving records (#953)

• Adds end-to-end tests that unrecognized mappings (including export_json envelopes and metadata-only dicts) raise ValidationError and write nothing. Covers nodes/edges alias exports, ContextGraph.to_dict() round-trips, schema method key validation, and asserts no success log on failure.

tests/export/test_yaml_exporter_key_recognition.py

test_normalize_graph_payload.pyUnit and end-to-end tests for normalize_graph_payload() and exporter consistency (#956) +168/-0

Unit and end-to-end tests for normalize_graph_payload() and exporter consistency (#956)

• Tests vocabulary resolution rules, conflict handling, and recognition behavior (including require_recognized=False). Includes integration checks that multiple exporters preserve records for JSONExporter round-trip shapes and nodes/edges payloads.

tests/utils/test_normalize_graph_payload.py

Documentation (2) +39 / -0
export.mdDocument YAML exporter key contract and aliasing behavior +5/-0

Document YAML exporter key contract and aliasing behavior

• Adds a note that YAML exporters read entities/relationships/triplets and accept nodes/edges as aliases. Documents that unrecognized non-empty mappings raise ValidationError instead of exporting empty collections.

docs/reference/export.md

export_usage.mdAdd Accepted Input section for YAML exports +34/-0

Add Accepted Input section for YAML exports

• Documents that YAML exports require mapping input (ProcessingError otherwise) and reject non-empty unrecognized mappings (ValidationError). Lists recognized keys per YAML method and provides examples, including ContextGraph.to_dict() round-trip.

semantica/export/export_usage.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Presence-only key recognition ✓ Resolved 🐞 Bug ≡ Correctness
Description
normalize_graph_payload() (and the schema path’s _require_recognized_keys) treat a mapping as
“recognized” if any recognized key is present, even if all recognized collections are empty.
Payloads like {'entities': [], 'data': [...records...]} can therefore normalize/export to empty
collections and still silently drop records under unrecognized keys.
Code

semantica/utils/helpers.py[R686-688]

+    recognized = _ENTITY_KEYS + _RELATIONSHIP_KEYS + _TRIPLET_KEYS
+    if require_recognized and payload and not any(key in payload for key in recognized):
+        supplied = ", ".join(f"'{key}'" for key in sorted(map(str, payload)))
Evidence
Recognition checks only key in payload, so any empty recognized key suppresses the
ValidationError. Resolution then returns [] for empty recognized collections, while unrecognized
keys are ignored; YAML export uses this normalized result directly, so records can still be dropped
silently.

semantica/utils/helpers.py[627-647]
semantica/utils/helpers.py[686-699]
semantica/export/yaml_exporter.py[107-115]
semantica/export/yaml_exporter.py[226-238]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Key-recognition currently checks only for the *presence* of a recognized key, not whether any recognized collection actually contains records. This leaves a silent-data-loss hole:
- Example: `{"entities": [], "data": [<records>]}` passes recognition (because `entities` is present) and resolves to `entities=[]`, dropping `data`.
The same pattern exists in `_require_recognized_keys()` for schema exports.
## Issue Context
The PR’s goal is to prevent “structurally valid but empty” exports when the caller supplies a non-empty mapping the exporter doesn’t actually read. Presence-only recognition still permits empty exports when the real records are under unrecognized keys.
## Fix Focus Areas
- semantica/utils/helpers.py[627-699]
- semantica/export/yaml_exporter.py[107-115]
- semantica/export/yaml_exporter.py[226-238]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. export_yaml rejects list payloads 📎 Requirement gap ⚙ Maintainability
Description
PR Compliance ID 1 expects export_yaml to accept List[Dict[str, Any]] inputs, but this PR
changes the public signature to require a mapping and explicitly rejects non-mapping inputs
(including lists). If the checklist item is still current, this is a breaking behavior change;
otherwise the checklist should be updated to match the new API contract.
Code

semantica/export/methods.py[497]

+    data: Dict[str, Any],
Evidence
PR Compliance ID 1 requires list-of-dicts input to export_yaml to succeed per the declared
signature; the PR changes the function signature to data: Dict[str, Any] and adds explicit runtime
rejection of non-mapping inputs via _require_mapping(...), so list input will not be exported.

export_yaml must handle List[Dict[str, Any]] input per its declared signature
semantica/export/methods.py[496-517]
semantica/export/yaml_exporter.py[48-73]


3. Progress success on failure ✓ Resolved 🐞 Bug ◔ Observability
Description
SemanticNetworkYAMLExporter.export() calls export_semantic_network(), which stops progress tracking
with status="completed", before creating the output directory and writing the file. If
ensure_directory/open/write fails, progress tracking can still report a successful export even
though no output file was produced.
Code

semantica/export/yaml_exporter.py[R277-280]

file_path = Path(file_path)
-        ensure_directory(file_path.parent)
-
yaml_content = self.export_semantic_network(data, **options)
+        ensure_directory(file_path.parent)
Evidence
The progress tracker is stopped with status="completed" inside export_semantic_network() immediately
after YAML serialization, but directory creation and file writing happen later in export(). Failures
in those later steps won’t update the already-completed progress item.

semantica/export/yaml_exporter.py[233-258]
semantica/export/yaml_exporter.py[260-285]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SemanticNetworkYAMLExporter.export_semantic_network()` marks the export as completed in the progress tracker before `export()` performs the filesystem side effects (directory creation and file write). This can lead to false “completed” progress status when the actual export fails during `ensure_directory(...)` or file write.
## Issue Context
This PR intentionally moved `ensure_directory()` to run after serialization so validation failures leave no output directory behind. That ordering is fine, but the progress lifecycle should still reflect the *full* export-to-disk outcome.
## Fix Focus Areas
- semantica/export/yaml_exporter.py[233-258]
- semantica/export/yaml_exporter.py[260-285]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread semantica/export/methods.py
Comment thread semantica/export/yaml_exporter.py Outdated
Comment on lines +277 to +280
file_path = Path(file_path)
ensure_directory(file_path.parent)

yaml_content = self.export_semantic_network(data, **options)

ensure_directory(file_path.parent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Progress success on failure 🐞 Bug ◔ Observability

SemanticNetworkYAMLExporter.export() calls export_semantic_network(), which stops progress tracking
with status="completed", before creating the output directory and writing the file. If
ensure_directory/open/write fails, progress tracking can still report a successful export even
though no output file was produced.
Agent Prompt
## Issue description
`SemanticNetworkYAMLExporter.export_semantic_network()` marks the export as completed in the progress tracker before `export()` performs the filesystem side effects (directory creation and file write). This can lead to false “completed” progress status when the actual export fails during `ensure_directory(...)` or file write.

## Issue Context
This PR intentionally moved `ensure_directory()` to run after serialization so validation failures leave no output directory behind. That ordering is fine, but the progress lifecycle should still reflect the *full* export-to-disk outcome.

## Fix Focus Areas
- semantica/export/yaml_exporter.py[233-258]
- semantica/export/yaml_exporter.py[260-285]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread semantica/utils/helpers.py Outdated
@Sameer6305

Copy link
Copy Markdown
Collaborator

@pravit-amp can you handle the qodo findings before we review it.

Addresses the Qodo findings on semantica-agi#958.

Presence-only recognition (finding 1): checking that a recognized key is
present answered "did the caller use our vocabulary" when the question that
matters is "did anything the caller supplied survive". A payload like
{"entities": [], "data": [...records...]} cleared the check, resolved to
empty, and dropped every record under 'data' -- the silent-empty export by a
narrower route.

- utils/helpers.py — split the check in two. _require_recognized_keys keeps
  the presence rule; _require_nothing_dropped runs after resolution and
  refuses a payload that resolved to nothing while an unread key still holds
  records. Only a non-empty list counts as evidence: ContextGraph.to_dict()
  always carries a populated 'statistics' dict, and an empty graph must stay
  exportable, so 'metadata', 'statistics' and 'count' are named as context
  rather than records.
- export/yaml_exporter.py — the schema path had the same hole and now runs
  both checks through the shared helpers rather than its own copy, so the
  two vocabularies cannot drift apart in what counts as a silent-empty
  export.

Progress reported success on a failed write (finding 3): export_semantic_
network stops its tracking as completed once serialization returns, but
export() then creates the directory and writes the file. A failure there
left the tracker showing a completed export with no output.

- export/yaml_exporter.py — the serialization span now says it serialized,
  not that it exported, and export() opens its own span around the
  filesystem work that stops as failed on error. Nothing reports a completed
  export until the bytes are on disk.

Finding 2 (export_yaml no longer accepts List[Dict]) is the intended
resolution of semantica-agi#952 rather than a regression: wrapping a bare list under a
guessed key is what would mislabel the records. The signature, docstring and
PR description already record the narrowed contract.

Tests cover both directions of each fix, including that an empty
ContextGraph still exports and that a failing write is not reported as
completed.
@pravit-amp

Copy link
Copy Markdown
Contributor Author

@pravit-amp can you handle the qodo findings before we review it.

Findings 1 and 3 are fixed in ae190e4. On finding 2 — narrowing export_yaml to reject List[Dict] is the intended resolution of #952 rather than a regression against it: the old signature declared list support the code never had (it raised AttributeError), and wrapping a bare list under a guessed key would mislabel records that this format keeps distinct. The declared signature, docstring, and the Breaking Changes section of the PR description are updated to match.

@Sameer6305
Sameer6305 self-requested a review August 14, 2026 10:44

@Sameer6305 Sameer6305 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pravit-amp for putting this together. I appreciate the effort here, especially centralizing the entities/nodes and relationships/edges reconciliation instead of continuing to fix the same ambiguity independently in each exporter. The resolver design around populated-vs-empty aliases and refusing conflicting non-empty aliases is a solid direction, and the added end-to-end coverage is useful.

I reviewed the implementation against semantica-agi/main, including the connected exporters and public export paths. I found two issues that I think should be addressed before merge:

1. normalize_graph_payload() still accepts invalid collection values

semantica/utils/helpers.py_resolve_collection()

The resolver currently treats any truthy value as a collection and does:

if value:
    return list(value)

This means malformed payloads such as:

{"entities": "abc"}

are normalized to:

{"entities": ["a", "b", "c"], ...}

while:

{"entities": 42}

raises a raw:

TypeError: 'int' object is not iterable

The problem is that the new helper is now the central validation boundary, but invalid collection values can still either be silently transformed or leak low-level exceptions from list() into the exporters.

This is particularly risky because downstream exporters expect graph records, not arbitrary iterables. The resulting error then points at exporter internals rather than the malformed entities/nodes field.

Suggested fix: validate collection values before converting them. At minimum reject strings, bytes, mappings, and non-iterable scalar values with an actionable ValidationError. Ideally also validate that the resulting records have the expected mapping/record shape.

Please add regression tests for at least:

  • {"entities": "abc"}
  • {"entities": 42}
  • {"nodes": {"id": "n1"}}
  • equivalent relationship/edge cases

The resolver should either return a valid graph collection or fail at the normalization boundary.

2. Neo4j CSV still bypasses the new "unrecognized payload" rejection

semantica/export/neo4j_csv_exporter.py_normalize_graph()

The new helper is called as:

normalize_graph_payload(graph, require_recognized=False)

That means a non-empty mapping containing no recognized graph keys still normalizes to empty entities/relationships instead of raising.

For example, a payload such as:

{"data": [{"id": "e1"}]}

can still reach the Neo4j exporter as an empty graph and produce empty CSV output.

This is directly at odds with the central contract introduced by this PR: the helper's default behavior is specifically designed to prevent an unrecognized mapping from becoming a valid-looking empty export.

I understand why the object/attribute path in Neo4j is kept separate, but for an actual mapping there does not appear to be a reason to opt out of the recognized-key validation.

Suggested fix: use the normal normalize_graph_payload(graph) behavior for mapping inputs, while retaining the existing attribute-based path for graph objects. Add a regression test asserting that an unrecognized non-empty mapping raises ValidationError rather than producing empty CSV files.

Why I think these need to be fixed before merge

Both issues are at the boundary of the new shared normalization contract. The first allows malformed collection values to escape the new validation layer, and the second leaves one of the adopters capable of the exact silent-empty behavior this refactor is intended to eliminate.

Once these are addressed and the corresponding regression tests are added, the overall direction of the PR looks good from my side.

Current verdict: Request changes.

The changes are relatively contained, so I don't think the PR needs a redesign — tightening the normalization boundary and making the mapping path consistent in Neo4j should be sufficient.

Two gaps at the boundary the shared normalizer is supposed to own.

_resolve_collection() resolved on truthiness alone, so a recognized key
could still hold something that is not a collection of records:
{"entities": "abc"} normalized to three single-character "records", and
{"entities": 42} surfaced as a raw TypeError from list() inside whichever
exporter happened to read it, naming the exporter rather than the payload
key at fault. Collection values are now validated before conversion --
strings, bytes, mappings, and non-iterable scalars are rejected by key
name, and each element must be a mapping or an attribute-carrying object,
the two record shapes the exporters actually read. None stays legal as an
absent collection, the spelling a JSON round-trip produces for []; it
cannot hide dropped records, since _require_nothing_dropped() still runs.
Every spelling present is validated, not just the one that wins, so a
malformed alias is not excused by a well-formed canonical key.

Neo4jCSVExporter._normalize_graph() opted out of the recognized-key check
for mappings, which left it able to turn {"data": [...]} into header-only
CSVs indistinguishable from a genuinely empty graph -- the exact failure
the rest of the change exists to prevent. Mapping payloads now go through
normalize_graph_payload() on its default terms. The attribute path for
graph objects is untouched. With no caller left opting out, the
require_recognized flag is removed rather than kept as a way back into
the silent-empty export.

Regression tests cover the malformed values end to end through every
export path that reads the normalizer, and assert the rejected Neo4j
export writes no CSV files.
@pravit-amp

Copy link
Copy Markdown
Contributor Author

Thanks @pravit-amp for putting this together. I appreciate the effort here, especially centralizing the entities/nodes and relationships/edges reconciliation instead of continuing to fix the same ambiguity independently in each exporter. The resolver design around populated-vs-empty aliases and refusing conflicting non-empty aliases is a solid direction, and the added end-to-end coverage is useful.

I reviewed the implementation against semantica-agi/main, including the connected exporters and public export paths. I found two issues that I think should be addressed before merge:

1. normalize_graph_payload() still accepts invalid collection values

semantica/utils/helpers.py_resolve_collection()

The resolver currently treats any truthy value as a collection and does:

if value:
    return list(value)

This means malformed payloads such as:

{"entities": "abc"}

are normalized to:

{"entities": ["a", "b", "c"], ...}

while:

{"entities": 42}

raises a raw:

TypeError: 'int' object is not iterable

The problem is that the new helper is now the central validation boundary, but invalid collection values can still either be silently transformed or leak low-level exceptions from list() into the exporters.

This is particularly risky because downstream exporters expect graph records, not arbitrary iterables. The resulting error then points at exporter internals rather than the malformed entities/nodes field.

Suggested fix: validate collection values before converting them. At minimum reject strings, bytes, mappings, and non-iterable scalar values with an actionable ValidationError. Ideally also validate that the resulting records have the expected mapping/record shape.

Please add regression tests for at least:

  • {"entities": "abc"}
  • {"entities": 42}
  • {"nodes": {"id": "n1"}}
  • equivalent relationship/edge cases

The resolver should either return a valid graph collection or fail at the normalization boundary.

2. Neo4j CSV still bypasses the new "unrecognized payload" rejection

semantica/export/neo4j_csv_exporter.py_normalize_graph()

The new helper is called as:

normalize_graph_payload(graph, require_recognized=False)

That means a non-empty mapping containing no recognized graph keys still normalizes to empty entities/relationships instead of raising.

For example, a payload such as:

{"data": [{"id": "e1"}]}

can still reach the Neo4j exporter as an empty graph and produce empty CSV output.

This is directly at odds with the central contract introduced by this PR: the helper's default behavior is specifically designed to prevent an unrecognized mapping from becoming a valid-looking empty export.

I understand why the object/attribute path in Neo4j is kept separate, but for an actual mapping there does not appear to be a reason to opt out of the recognized-key validation.

Suggested fix: use the normal normalize_graph_payload(graph) behavior for mapping inputs, while retaining the existing attribute-based path for graph objects. Add a regression test asserting that an unrecognized non-empty mapping raises ValidationError rather than producing empty CSV files.

Why I think these need to be fixed before merge

Both issues are at the boundary of the new shared normalization contract. The first allows malformed collection values to escape the new validation layer, and the second leaves one of the adopters capable of the exact silent-empty behavior this refactor is intended to eliminate.

Once these are addressed and the corresponding regression tests are added, the overall direction of the PR looks good from my side.

Current verdict: Request changes.

The changes are relatively contained, so I don't think the PR needs a redesign — tightening the normalization boundary and making the mapping path consistent in Neo4j should be sufficient.

Thanks for the careful read — both findings were real and are now fixed in a
follow-up commit on this branch.

1. _resolve_collection() now validates before converting.

A new _coerce_records(key, value) runs ahead of any truthiness test or
list() call, so a recognized key can no longer hold a non-collection. It
raises ValidationError naming the offending key for strings/bytes
({"entities": "abc"} is no longer three character-records), mappings
({"nodes": {"id": "n1"}}, with a hint to wrap it in a list or supply its
values), and non-iterable scalars ({"entities": 42} is a ValidationError,
not a TypeError leaked from inside an exporter). Elements are validated too,
with the index called out.

On your "ideally also validate record shape": records are accepted as either
mappings or attribute-carrying objects, rather than mappings only —
Neo4jCSVExporter._record_to_dict() legitimately handles dataclasses and
objects with __dict__, so a mapping-only rule would have broken that path.

Two judgment calls worth surfacing, happy to change either:

  • None is read as an absent collection, the spelling a JSON round-trip
    produces for []. It can't hide data loss: {"entities": None, "data": [...]}
    is still rejected by the _require_nothing_dropped() check.
  • Every spelling present is validated, not just the one that wins, so a
    malformed nodes isn't excused by a well-formed entities.

2. Neo4j CSV now uses the default contract for mappings.

_normalize_graph() calls normalize_graph_payload(graph) for mappings; the
attribute path for graph objects is untouched. Since that was the only opt-out
in the tree, I also removed the require_recognized flag rather than leave a
documented route back into the silent-empty export — it was introduced in this
PR and had no other caller. Easy to restore as a public escape hatch if you'd
rather keep it.

Tests — 15 new cases covering the four shapes you listed across all five
collection keys, plus object/dataclass/tuple acceptance, the null and
malformed-alias cases, and an end-to-end pass asserting no malformed value
reaches export_arango / export_neo4j_csv / export_lpg / export_yaml.
The Neo4j regression asserts an unrecognized non-empty mapping raises
ValidationError and that nodes.csv / relationships.csv are never
created — the payload is normalized before any file is opened.

Docs updated in export_usage.md and docs/reference/export.md, including an
"Accepted Input" section for the Neo4j CSV path.

Full suite matches the pre-change baseline on my machine (the failures there
are all missing optional deps in semantic_extract/visualization, unrelated
to this change); black and flake8 are clean on the touched code.

@pravit-amp
pravit-amp requested a review from Sameer6305 August 14, 2026 21:00
Fix 1 -- _require_usable_schema silent data loss (P1):
_require_usable_schema() passed all values from _SCHEMA_KEYS into
_require_nothing_dropped() as evidence that records survived.  Scalar
metadata fields such as version='1.0' and uri='http://...' are truthy
strings, so any one of them caused _require_nothing_dropped() to return
early and silently discard records stored under an unread key alongside
them (e.g. {'version': '1.0', 'nodes': [{'id': 'c1'}]}).  Fixed by
building the resolved list from only non-empty list/tuple values of
recognised schema keys.

Fix 2 -- _is_record accepts modules and type objects (P2):
_is_record() accepted any object with __dict__, which includes Python
modules and class objects.  Elements that passed _coerce_records then
reached exporters and raised AttributeError (e.g. module 'math' has no
attribute 'get') rather than a ValidationError at the validation
boundary.  Fixed by excluding types.ModuleType and type from the
__dict__ branch while preserving support for all user-defined
attribute-bearing record objects.

Tests: 101 tests pass across
  tests/utils/test_normalize_graph_payload.py
  tests/export/test_yaml_exporter_key_recognition.py
  tests/export/test_yaml_exporter_input_validation.py
  tests/export/test_neo4j_csv_exporter.py
Sameer6305
Sameer6305 previously approved these changes Aug 15, 2026

@Sameer6305 Sameer6305 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pravit-amp for putting this together. The centralization of graph-payload normalization is a solid improvement, and I appreciate the effort to keep the behavior consistent across the exporters instead of continuing to handle entities/nodes and relationships/edges independently.

I also appreciate the follow-up on the review findings. The requested fixes were addressed cleanly in 49fe0174, including validation of invalid collection values and making the Neo4j mapping path follow the shared recognized-key validation contract.

During the follow-up review, we also identified two additional edge cases in the new validation boundary:

  • _require_usable_schema() could treat scalar schema metadata such as version, uri, title, or description as evidence that records had been exported, allowing records under an unread key to be silently dropped.
  • _is_record() accepted modules and class/type objects through the generic __dict__ path, allowing invalid records to reach exporter internals instead of failing at the normalization boundary.

These were addressed in def27781 (fix(export): close YAML schema and record validation gaps), along with regression coverage. The changes are intentionally small and scoped to the validation boundary.

I verified the relevant coverage after the fixes:

  • 101 targeted tests passing across the normalization, YAML validation, YAML key-recognition, and Neo4j exporter suites.
  • git diff --check clean.
  • Regression coverage added for the newly identified cases.
  • The existing support for mappings, dataclasses, and attribute-bearing record objects is preserved.

Overall, the implementation now has a consistent validation boundary, rejects malformed collection values with actionable errors, prevents unrecognized mappings from becoming silent empty exports, and keeps the different graph-payload aliases consistent across the relevant exporters.

Thanks again for the careful implementation and for addressing the review feedback promptly.

Also One separate follow-up point from the broader review: we also noticed that some validation error paths can reflect caller-controlled key names without a length bound, which can result in unnecessarily large error/log messages for extremely long input keys. This is pre-existing and not introduced by this PR, so it does not block approval.

I'll raise this separately shortly so it can be tracked independently. @pravit-amp, if you're okay taking that follow-up, feel free to pick it up separately.

@KaifAhmad1, approved from our side. The PR is ready for your final review and merge.

…graph_payload

LPGExporter and ArangoAQLExporter called normalize_graph_payload() with no
type guard, so non-mapping input raised ValidationError from inside the
resolver while the YAML and Neo4j exporters raised ProcessingError for the
identical mistake -- inconsistent with the exception-type contract this PR
establishes. Both now use the shared _require_mapping() guard (moved from
yaml_exporter.py into utils/helpers.py so all three can use it).

Neo4jCSVExporter._normalize_graph checked isinstance(graph, dict), so a
non-dict Mapping (MappingProxyType, ChainMap) fell through to the
object-attribute branch and was rejected, even though the identical payload
exported fine via the other three exporters. Now checks isinstance(graph,
Mapping).

normalize_graph_payload() accepts dataclass/attribute-bearing object
records, but LPGExporter/ArangoAQLExporter call .get(...) directly on
resolved entities -- an object-shaped record passed validation only to
crash with a raw AttributeError once used, the exact failure this
boundary exists to prevent. Records are now converted to plain dicts at
the boundary (_coerce_records -> new _record_to_dict), so every consumer
gets a uniform shape regardless of which reading the caller used.

Two non-empty spellings of the same collection holding identical records
in a different order were rejected as conflicting, since the check used
plain list equality. Comparison is now an order-independent multiset of
each record's canonical JSON form.
…rdening

Documents the full arc of semantica-agi#958 -- the normalize_graph_payload()
centralization, YAML input validation, both review rounds from
@Sameer6305, and the exception-type/record-shape follow-up fixes -- plus
closes semantica-agi#956, semantica-agi#952, semantica-agi#953.

@KaifAhmad1 KaifAhmad1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pravit-amp for the centralization work here, and @Sameer6305 for two thorough review rounds — this got the shared boundary to a solid place. Did a final pass before merge and found four more gaps that the earlier rounds didn't reach, all fixed in ae1d5958:

  • LPGExporter/ArangoAQLExporter called normalize_graph_payload() with no type guard, so non-mapping input raised ValidationError from inside the resolver while YAML/Neo4jCSVExporter raised ProcessingError for the identical mistake — inconsistent with the exception-type contract this PR itself documents. The _require_mapping() guard is now shared from utils/helpers.py and used by all three.
  • Neo4jCSVExporter._normalize_graph checked isinstance(graph, dict), so a non-dict Mapping (MappingProxyType, ChainMap) was rejected even though the identical payload exported fine via the other three exporters. Now checks isinstance(graph, Mapping).
  • normalize_graph_payload() accepts dataclass/attribute-bearing object records, but LPGExporter/ArangoAQLExporter read them with .get(...) — an object-shaped record passed validation only to crash with a raw AttributeError once used. Records are now converted to plain dicts at the boundary (_coerce_records → new _record_to_dict), so every consumer gets a uniform shape.
  • Two non-empty spellings of the same collection holding identical records in a different order were rejected as conflicting (plain list equality). Comparison is now an order-independent multiset of each record's canonical JSON form.

Added regression coverage for all four in tests/utils/test_normalize_graph_payload.py, updated 4 existing tests that asserted the old (unconverted) object-passthrough behavior, and merged main in to resolve the CHANGELOG.md conflict (d056a8df) — that was the only conflicting file, purely an adjacent-insertion clash with #957's entry.

Full suite post-merge: pytest tests/export tests/utils tests/context tests/test_export_module.py tests/test_export_methods_wrapper.py tests/test_notebooks_simulation.py — 771 passed, 4 skipped. black/isort/flake8 --max-line-length=88 clean on every touched line. python -m build succeeds. Also added a CHANGELOG entry (838639b3) covering the full arc of this PR: @pravit-amp's original centralization work, both of @Sameer6305's review rounds, and this follow-up.

Nice collaborative fix, @pravit-amp and @Sameer6305 — approving to merge.

@KaifAhmad1
KaifAhmad1 merged commit 5579851 into semantica-agi:main Aug 15, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment