Skip to content

XML reader: pool repeated attribute strings, and make attribute filters actually filter - #205

Merged
villelaitila merged 2 commits into
softagram:mainfrom
villelaitila:feature/pool-attribute-strings-on-parse
Sep 11, 2026
Merged

villelaitila merged 2 commits into
softagram:mainfrom
villelaitila:feature/pool-attribute-strings-on-parse

Conversation

@villelaitila

@villelaitila villelaitila commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Two commits against the XML reader. The first is the memory change; the second is a filtering defect found while reading those same lines closely.


1. Pool repeated attribute strings

What

SGraphXMLParser now keeps a per-parse string pool and routes attribute names, attribute values, element types and association dependency types through it, so equal strings collapse onto one object instead of one object per occurrence.

Why

expat hands out a fresh str for every attribute name and value it reports, and sgraph models repeat both heavily. Measured on a 250k-element model: 2.4M attribute occurrences drawn from 181 distinct names and ~90k distinct values, yet attribute names and values alone retained 2,831,679 string objects where 90,019 suffice. Attribute-name objects accounted for 2,158,742 of those.

Effect

Retained RSS after parse, three real models, best of three runs:

model before after
openclaw (249k elements) 627 MB 514 MB −18%
intra 535 MB 429 MB −20%
odoo-fullstack 473 MB 400 MB −15%

RSS is allocator-noisy, so the same thing counted exactly — every distinct string object the model retains, element names included:

objects bytes
before 3,235,297 213.2 MB
after 339,472 54.6 MB

Attribute-name objects: 2,158,742 → 181. Parse time is unchanged within run-to-run noise.

The saving is a property of the data, not a guarantee. A synthetic model in which no name and no value repeats gains nothing and pays about 5% more RSS growth during the parse for a pool it cannot use. Real models never look like that, because analyzers draw attribute names from a fixed vocabulary — so names pool even when values do not.

Why a per-parse dict rather than sys.intern()

sys.intern() collapses the same strings just as well: measured over this model it retains the identical 54.6 MB, and across two models held at once it shares only 0.2 MB more than this pool does. It also releases normally — runtime-interned strings are mortal on CPython 3.12/3.13/3.14, so this is not a leak argument.

It is not used because it mutates interpreter-global state from a hot parsing loop for no measured gain, and because it raises TypeError on the None that an attribute written as <a n="x"/> legitimately produces. A dict on the parser instance keeps both the mechanism and the strings' lifetime local to the reader.

Verification

  • New tests/test_parse_string_pooling.py (11 tests), written before the change; 9 fail against unmodified main. They assert object identity only as a proxy for "stored once" - the module docstring says so, because identity is not an sgraph guarantee and nothing in the library compares attribute strings with is. The two that pass on main are invariant guards (no process-global interning; <a n="x"/> with no v still yields None) — both were mutation-tested by swapping the pool for sys.intern, which breaks both. All six pooled call sites are covered, including the whitelisted_elem_attributes branch.
  • Full suite: 587 passed in the project venv. (Without the optional spycy extra one cypher test skips - unrelated to this change.)
  • Equivalence beyond the tests: a canonical dump of every element path with its attributes, and every association's endpoints, deptype and attributes, is identical to the pre-change reader on two 250k-element models; to_deps output is byte-identical. (to_xml output differs run to run on main too — pre-existing nondeterminism in id assignment, unrelated to this change.)
  • flake8 --max-line-length=100: no new findings.

Deliberately out of scope

  • parse_deps_lines builds attributes by string slicing and is not pooled. The deps format is documented as unsuitable for very large models, so the duplication there does not pay for the extra code.
  • Element names are not pooled: 249,434 objects for 142,982 distinct values, worth ~3 MB, against a hash of every path segment.
  • Association attribute dicts are the largest remaining item — 224k slots, 131k distinct dict objects, only 9,210 distinct contents, ~23 MB — but they are mutable and already aliased across the deps of one <r>, so pooling them is a separate change with its own risk.

2. Make attribute filters reach every attribute they name

elem_attribute_filters and assoc_attribute_filters were only partly wired up, in two independent ways. Pre-existing, not introduced by the commit above — but that commit is what got these lines read closely.

The <a> handler returned early only when both ignore-all flags were set, so passing IGNORE * for one kind alone did nothing to the <a n=".." v=".."/> spelling.

Association attributes written inline on <r> consulted no filter at all, so no assoc filter — ignore-all, blacklist or whitelist — ever reached that spelling. The equivalent loop for <e> already applied element filters; this brings <r> in line with it.

Measured on the same 250k-element model, asking for IGNORE * on both lists:

before after
association attributes kept 231,927 0
loaded model 465 MB 426 MB

Structural values are deliberately left alone, as before: an element's type and an association's deptype describe what the thing is rather than data attached to it, and no attribute filter reaches them. test_structural_type_survives_every_filter pins that down so it reads as intent rather than as another gap.

A load that passes no filters is unaffected — the canonical dump of two 250k-element models is identical to before, which is what almost every caller does.

There were no tests for attribute filters at all, which is why this survived. tests/test_parse_attribute_filters.py now covers both filter kinds against both spellings; 5 of its 10 tests fail against unmodified main.

https://claude.ai/code/session_01XqfUmwZ3VYazHpKAFnYDFF

@villelaitila
villelaitila force-pushed the feature/pool-attribute-strings-on-parse branch from fefbba1 to 424c56a Compare September 11, 2026 14:49
… occurrence

expat hands out a fresh str object for every attribute name and value it
reports, and sgraph models repeat both heavily. A 250k-element model carries
2.4M attribute occurrences drawn from 181 distinct names and ~90k distinct
values, so its attribute names and values alone retained 2,831,679 string
objects where 90,019 suffice.

SGraphXMLParser now keeps a per-parse dict and routes attribute names,
attribute values, element types and association dependency types through it,
collapsing equal strings onto one object.

Measured on three real models (retained RSS after parse, best of three runs):

  openclaw        627 MB -> 514 MB  (-18%)
  intra           535 MB -> 429 MB  (-20%)
  odoo-fullstack  473 MB -> 400 MB  (-15%)

Counted exactly rather than via RSS, the strings the openclaw model retains
(element names, attribute names and values, dependency types) drop from
3,235,297 objects / 213.2 MB to 339,472 objects / 54.6 MB. Attribute-name
objects alone go from 2,158,742 to 181. Parse time is unchanged within
run-to-run noise.

sys.intern() would collapse the same strings just as well - measured over this
model it retains the identical 54.6 MB, and across two models loaded at once it
shares only 0.2 MB more than this pool does. It is not used because it mutates
interpreter-global state from a hot parsing loop for no measured gain, and
because it raises TypeError on the None that an attribute written as <a n="x"/>
legitimately produces. A dict on the parser instance keeps both the mechanism
and the strings' lifetime local to the reader.

The saving is a property of the data, not a guarantee: a synthetic model in
which no name and no value repeats gains nothing and pays about 5% more RSS
growth during the parse. Real models never look like that, because analyzers
draw attribute names from a fixed vocabulary.

Verified beyond the new tests: canonical dumps of every element path, its
attributes, and every association's endpoints, deptype and attributes are
identical to the pre-change reader on two 250k-element models, and to_deps
output is byte-identical.

Claude-Session: https://claude.ai/code/session_01XqfUmwZ3VYazHpKAFnYDFF
@villelaitila
villelaitila force-pushed the feature/pool-attribute-strings-on-parse branch from 424c56a to fa49c34 Compare September 11, 2026 14:50
elem_attribute_filters and assoc_attribute_filters were only partly wired up, in
two independent ways.

The <a> handler returned early only when BOTH ignore-all flags were set, so
passing `IGNORE *` for one kind of attribute alone did nothing to the <a n=".."
v=".."/> spelling. `assoc_attribute_filters=['IGNORE *']` kept every association
attribute; `elem_attribute_filters=['IGNORE *']` kept every element attribute
written as an <a> child.

Separately, association attributes written inline on the <r> tag went through a
loop that consulted no filter at all, so no assoc filter - ignore-all, blacklist
or whitelist - ever reached that spelling. The equivalent loop for <e> already
applied element filters; this brings <r> in line with it.

Each kind of filter now governs its own kind of attribute, in both spellings.
On a 250k-element model, `IGNORE *` on both filter lists now drops the 231,927
association attributes it previously kept, taking the loaded model from 465 MB
to 426 MB.

Structural values are deliberately left alone, as before: an element's type and
an association's deptype describe what the thing is rather than data attached to
it, and no attribute filter reaches them.

No behaviour changes for a load that passes no filters - the canonical dump of
two 250k-element models is identical to before. There were no tests for
attribute filters at all, which is why this survived; tests/test_parse_attribute_filters.py
now covers both filter kinds against both spellings, and 5 of its 10 tests fail
without this change.

Claude-Session: https://claude.ai/code/session_01XqfUmwZ3VYazHpKAFnYDFF
@villelaitila villelaitila changed the title XML reader: pool repeated attribute strings instead of one object per occurrence XML reader: pool repeated attribute strings, and make attribute filters actually filter Sep 11, 2026
@softagram-bot

Copy link
Copy Markdown

Softagram Impact Report for pull/205 (head commit: 8a32290)

TL;DR Arch. Impact: 📈 +4 | Changed code files: 3 | Directly impacted code files: 53

⭐ Change Overview

Showing the changed files, dependency changes and the impact - click for full size
(Open in Softagram Desktop for full details)

⭐ Details of Dependency Changes (diagram)

details of dependency changes - click for full size
(Open in Softagram Desktop for full details)

🤖 AGENTS - machine-readable impact data (3 files changed, 53 impacted, +22/-0 deps)

Change overview

Head 8a32290f3f93 vs base b523dad6a06a. 3 code files changed. 53 unchanged files directly depend on the changed files (see Impacted files). Dependencies: 22 added, 0 removed. New external components: 0. Removed external components: 0.

Added dependencies (22)

from to type roles signal
sgraph/tests/test_parse_attribute_filters.py sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/test_parse_string_pooling.py sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/test_parse_attribute_filters.py External/PythonLibs/io import test→external regular
sgraph/tests/test_parse_string_pooling.py External/PythonLibs/io import test→external regular
sgraph/tests/test_parse_attribute_filters.py/test_assoc_blacklist_reaches_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_assoc_ignore_all_drops_association_attributes_in_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_assoc_whitelist_reaches_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_elem_blacklist_reaches_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_elem_ignore_all_drops_element_attributes_in_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_elem_whitelist_reaches_both_spellings sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_ignoring_everything_drops_everything sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_attribute_filters.py/test_without_filters_every_attribute_is_kept sgraph/tests/test_parse_attribute_filters.py/load func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_pooling_does_not_outlive_the_parse sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_association_attributes_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_attribute_names_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_attribute_values_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_dependency_types_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_element_types_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_inline_association_attribute_names_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_inline_attribute_names_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_repeated_inline_attribute_values_share_one_object sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular
sgraph/tests/test_parse_string_pooling.py/test_whitelisted_inline_attributes_are_pooled sgraph/tests/test_parse_string_pooling.py/files_of func_ref test→test regular

Removed dependencies (0)

None.

Impacted files (53)

Unchanged files that directly depend on files changed in this PR - check them for behavioral impact. Grouped by changed file; dependent paths starting with ./ are relative to the changed file's directory:

changed file directly impacted dependents
sgraph/src/sgraph/sgraph.py 53: ./init.py, ./algorithms/generalizer.py, ./algorithms/pagerank.py, ./algorithms/sgraphanalysis.py, ./algorithms/sgraphfiltering.py, ./analyzers/base.py, ./analyzers/code/python/ast_visitor.py, ./analyzers/code/python/python_analyzer.py, ./cli/compare.py, ./cli/filter.py, +43 more

Complete data

[]

📄 Full report

Impact Report explained. Give feedback on this report to support@softagram.com

@villelaitila
villelaitila merged commit f61aef8 into softagram:main Sep 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants