The Parser Was Fixed. The Extractor Was Not.
Episode 3 followed a request until it reached a working model route. The next problem was on the other side of generation: could the output become useful knowledge? A model could answer, and its response could be valid JSON, while the graph still received nothing it could use.
I wanted to finish a backlog of documents waiting to enter Rootweaver’s knowledge graph. The work initially looked like a throughput problem. The records then changed the diagnosis, exposed a repair on an unused client, and ended with a more useful decision than simply running the backlog again: keep the defensive code, but pause the backfill until its value justified another run.

A pattern can describe the shape of a book without supplying its pages. Conceptual illustration.
This chapter covers July 5–6, 2026. I owned direction, requirements, review and acceptance; agents assisted with diagnosis, implementation and checking. This is a curated reconstruction from three retained handovers and two historical source revisions, not a replay of every session. Runtime numbers below are attributed to those reports. The separately inspected code explains the mechanism. New synthetic checks help test that explanation without recreating the original deployment or calling the historical model.
What the graph needed from the model
A document graph is built from more than an embedding. The system first needs to identify entities in a piece of text, resolve whether they refer to things already known, and prepare the resulting information for storage and search. If extraction fails before those later stages, a faster embedding service cannot repair the missing input.
Rootweaver used Graphiti, maintained by Zep, for this work. The July diagnosis identifies graphiti-core 0.29.2 as the installed dependency. Rootweaver supplied its own client integration; this repair did not edit Graphiti’s upstream schemas. That distinction matters because the adaptation had to happen at the boundary between the model response and the values Graphiti expected.
There were two relevant response types. ExtractedEntities held a list of extracted things. Each item needed fields including name, entity_type_id and episode_indices. NodeResolutions held decisions about whether a proposed entity matched a candidate already in the graph. Its outer object needed an entity_resolutions list, with records containing id, name and duplicate_candidate_id.
The names are quite literal once separated. An entity is a thing the text mentions. Resolution is the attempt to recognise an existing thing rather than create another copy. The response model is the software contract for carrying those results. It is not the language model itself.
Pydantic’s model documentation explains validation against declared types and constraints. In this case, passing that validation was a necessary step toward ingestion. It was never an independent check that an entity was actually supported by the document, that two names referred to the same thing, or that adding the graph would improve an answer.
| Boundary | Question it answers | What a pass still leaves open |
|---|---|---|
| JSON decoding | Can the text be read as JSON? | Whether it has the required fields |
| Response validation | Does the value fit the expected model? | Whether the content is grounded in the document |
| Graph ingestion | Can the accepted information be processed and stored? | Whether the stored information is useful |
| Task evaluation | Does the graph improve a relevant task? | Whether that result generalises beyond the tested cases |
The original investigation crossed these boundaries in the wrong order. It saw slow embedding and initially treated throughput as the obstacle to completing the backlog. The later records show why that explanation was incomplete.
The faster component was downstream of the failure
The earlier July 5 handover proposed using GPU embedding to speed the run. It described a remote 235B extraction route through LiteLLM and OpenRouter, with CPU embedding as the apparent bottleneck. That document is retained unchanged in the source pack because its later supersession is part of the story, rather than an inconvenient draft to discard.
Its warning and the subsequent diagnosis say that the GPU burst was executed and reverted, with the ordinary model service restored. The graph’s node count nevertheless stayed at 230. Dense documents were failing extraction validation before embedding could help. One observed shape lacked the entity_resolutions wrapper. Another contained strings or schema-like objects where actual extracted entity records were expected.
Those are historical reports of the run. I have not recovered the complete original provider response and cluster log bundle for independent replay. The reports support the change in diagnosis; they do not establish that every document failed for one identical reason, or that embedding could never become a bottleneck after extraction was repaired.
The point was narrower and actionable: this run could not be rescued by accelerating a stage it had not successfully reached. CPU embedding could still be slow. It was simply the wrong first fix for the failing documents.
The original note also called the remote lane proven because it returned clean JSON without earlier truncation or authentication failures. Those observations can coexist with the later failure. A syntactically readable response can still violate the extraction contract. Calling the endpoint successfully and completing a graph backfill are different acceptance tests.
I needed the next change to inspect what could actually be recovered. Re-wrapping a complete result was defensible. Making up missing entities to satisfy a schema was not. The diagnosis-first handoff explicitly allowed the answer to be that the output was unrecoverable and needed a different extraction route.
Repairing a wrapper without inventing a fact
The first recoverable case was a missing envelope. If the model had already supplied a complete node-resolution record, a client could put it inside the list that Graphiti expected. It did not need to infer a new name or choose a new duplicate candidate.
Source: graphiti_client.py, _coerce_node_resolutions_output, retained revision 99f4f215. This exact excerpt handles a single complete record and a bare list:
if _is_node_duplicate(value):
return {"entity_resolutions": [value]}, True
if isinstance(value, list):
coerced, _dropped, _changed = _coerce_node_duplicate_items(
value,
response_model_name,
)
return {"entity_resolutions": coerced}, True
Explain this code line by line
- The first condition asks whether the value is a dictionary containing the three required field names for a node-resolution record. It does not prove that their values are semantically correct.
- The first return adds the missing outer dictionary and makes a one-item list. The original record is retained rather than regenerated.
Truetells the caller that the shape changed.- The next condition handles a list returned without its outer dictionary.
_coerce_node_duplicate_itemskeeps dictionary items with the expected keys and counts rejected items. The underscores indicate that this caller does not use the returned count and change flag directly.- The final return adds the expected wrapper around that filtered list. Later validation still has a job to do.
Here is a synthetic example, chosen to expose the envelope change. It is not an original model response or a real duplicate decision:
{"id": 0, "name": "Rootweaver", "duplicate_candidate_id": 13}
After the wrapper repair, the same fields sit inside the expected list:
{
"entity_resolutions": [
{"id": 0, "name": "Rootweaver", "duplicate_candidate_id": 13}
]
}
id identifies the proposed item within this example. name carries its supplied label. duplicate_candidate_id carries the supplied candidate decision. The normaliser preserves all three values. This example says nothing about whether candidate 13 exists or is the correct match. It demonstrates a recoverable structural mismatch rather than a new fact being inferred.
The helper is deliberately limited. It checks for dictionary keys before re-wrapping; it does not thoroughly validate the type and meaning of every value. A record with all required keys and a nonsensical value can survive this filter and still fail later. That is a reason to keep downstream validation, rather than describe the patch as accepting arbitrary output safely.
A string containing an entity is different from a schema
Entity extraction needed a different adaptation. Sometimes the list contained a JSON object encoded as a string. The information was present, but packaged one level deeper than expected. Python’s json.loads can turn that string back into a value. It cannot recover an entity that the model never supplied.
Source: graphiti_client.py, _coerce_extracted_entity_items, revision 99f4f215. This exact loop separates those cases:
for value in values:
candidate = value
if isinstance(value, str):
candidate, parsed = _parse_stringified_json(value)
changed = changed or parsed
if _is_extracted_entity(candidate):
coerced.append(candidate)
else:
dropped += 1
changed = True
Explain this code line by line
- The loop examines each supplied list item independently.
candidateinitially refers to the original item. Dictionary items can therefore proceed without being encoded and decoded again.- String items get one JSON decoding attempt. The helper catches a JSON decoding error and leaves an undecodable string as it was.
parsedrecords whether decoding succeeded.changedstays true once any earlier item needed a transformation._is_extracted_entitychecks that the candidate is a dictionary withname,entity_type_idandepisode_indiceskeys.- A candidate with those keys is retained. This is a structural filter; it does not check the document for factual support.
- Anything else increments
droppedand marks the result changed. No replacement name or entity type is invented.
Consider this synthetic extraction response. The first item contains a recoverable entity as encoded JSON. The second resembles an instruction describing a type rather than an extracted instance:
{
"extracted_entities": [
"{\"name\":\"Graphiti\",\"entity_type_id\":1,\"episode_indices\":[0]}",
{"type": "Object", "properties": {"name": {"type": "string"}}}
]
}
The first item decodes to an object. It has all three keys, so the filter retains it. name is the extracted label, entity_type_id is a supplied type identifier, and episode_indices points to the supplied episode positions associated with it. The example’s 0 is just an illustrative index. The second item supplies no actual entity name or type identifier. It is dropped. The resulting response contains one candidate entity:
{
"extracted_entities": [
{"name": "Graphiti", "entity_type_id": 1, "episode_indices": [0]}
]
}
That final object is eligible for the next validation step. It does not become true because a parser accepted it. In a real run, I would still need to inspect its relationship to the source document and the resulting graph.
One response, two possible outcomes
The diagram follows one illustrative response through the intended repaired boundary. It omits network retries, relation extraction, database transactions and retrieval. Its dots show direction, not observed traffic. The branch to rejection is essential: an empty list after filtering can be a correctly handled failure to extract anything useful.
When all items are dropped, the retained code emits a second warning explaining that the model returned schema-like or non-entity output and suggesting enforced structured output or a conformant model. That warning is evidence of the client’s diagnosis, not a universal certification of a named alternative. Current OpenRouter structured-output documentation explains the provider and model support involved. It is useful background, not proof of what a particular provider enforced in July.
The first fix passed while the application used another client
The first implementation landed at revision 1340daf8. It added the normalisation helpers and invoked them inside GuidedJSONOpenAIClient. The July 6 handover reports a passing verifier and then records the discovery that this class was a retired path. The retained source makes the wiring problem inspectable: the constructor still instantiated OpenAIGenericClient.
A helper can be correct for the input it receives and still have no effect on the application if the application never calls it. Testing the guided client demonstrated behaviour on that client. It did not demonstrate that GraphitiClient.initialize selected it for the running integration.

The repair needed to follow the connected mechanism. The unused drum represents the retired client, not a second production system. Conceptual illustration.
This is where the second change matters. Revision 99f4f215 introduced CoercingOpenAIGenericClient, a subclass of the generic client already used by the application. The constructor changed to instantiate that subclass. The response adaptation then happened after the parent returned its result and before Graphiti’s later validation consumed it.
The historical source keeps the normalisation narrow. It checks the requested response model name and handles NodeResolutions and ExtractedEntities. Other models pass through. Turning GRAPHITI_COERCE_STRUCTURED_OUTPUT off also returns the original result. This switch controls the compatibility adaptation; it does not switch off Graphiti’s need for valid data.
Source: graphiti_client.py, CoercingOpenAIGenericClient.generate_response, revision 99f4f215. This exact excerpt is the response-selection part, after the parent call:
if not _structured_output_coercion_enabled() or response_model is None:
return result
model_name = _response_model_name(response_model)
if model_name == "NodeResolutions":
coerced, changed = _coerce_node_resolutions_output(result)
elif model_name == "ExtractedEntities":
coerced, changed = _coerce_extracted_entities_output(result)
else:
return result
return coerced if changed else result
Explain this code line by line
- The first condition preserves the parent result when adaptation is disabled or no response model was requested.
_response_model_nameextracts the requested contract’s name. It does not inspect or classify the document.- Node-resolution responses go to the wrapper and item filter described earlier.
- Entity-extraction responses go to the string-decoding and entity filter.
- An unrelated response model returns unchanged, keeping this patch from rewriting other kinds of output.
- The final return uses the adapted value only when a helper reports a change. An already conforming response keeps the original object.
The constructor is the crucial connection. Source: GraphitiClient.initialize, same revision, exact excerpt:
llm_client = CoercingOpenAIGenericClient(
config=llm_config, max_tokens=graphiti_max_tokens
)
Explain this code line by line
llm_clientis the object passed onward into the Graphiti integration.- Instantiating the new subclass puts the compatibility behaviour on the client’s real construction path.
configcarries the language-model client configuration.max_tokensis forwarded explicitly; this change does not create a new model or improve its extraction quality.- This constructor evidence distinguishes the second repair from merely adding another tested helper.
The rehome test file reflected that boundary. It included wrapper recovery, encoded entity recovery, the off switch, conforming-value identity, unrelated-model pass-through and forwarding of call arguments. It also initialised GraphitiClient with substituted dependencies and inspected which client it built. That last check was especially useful because it addressed the mistake the first implementation had missed.
These are controlled unit tests with fake responses and recording dependencies. They can establish the chosen client type and the transformation of supplied values. They cannot prove provider compliance, original cluster rollout, graph quality or the usefulness of a full backfill. The dated handover reports the historical verifier passes; the source pack separates that report from the new isolated checks used for this draft.
A crash-free probe still gave a reason to stop
The July 6 handover records a small live probe after the rehome. It says the adaptation was firing on the active path, there were no Pydantic crashes in that probe, and document_knowledge grew from 230 to 242 nodes. It also reports 163 dropped entities described as schema-echo output. The backfill was then parked.
| Reported observation | Supported reading | Tempting conclusion it does not support |
|---|---|---|
| Coercion warning appeared | The adaptation was being exercised in the reported probe | Every graph call used the repaired client |
| Zero Pydantic crashes | No such crash was reported during that probe | All possible malformed responses are handled |
| 230 → 242 nodes | Net graph-node count rose by 12 | Twelve independently verified correct facts |
| 163 dropped items | The filter rejected a substantial amount of reported output | An extraction accuracy percentage |
| Backfill parked | The full run was paused as a deliberate decision | Graph ingestion was abandoned permanently |
There is no valid accuracy calculation from those two counts. Dropped output items and net new graph nodes are different units. Resolution can merge candidates, multiple documents can mention the same thing, and the retained handover does not provide a complete denominator of attempted documents, extracted entities or adjudicated ground truth.

Keeping the line moving is useful only when the accepted output deserves to move forward. The scene represents the decision to inspect yield before resuming the backlog. Conceptual illustration.
The probe therefore supported a limited engineering improvement: the system could handle some structural failures without crashing, and some graph growth was reported. It also exposed the cost of calling that a completed extraction repair. Dropping invalid entries can turn a hard error into an apparently successful empty result. A quiet process needs a measure of what it preserved and what it rejected.
The handover brought a previous graph-enrichment comparison back into the decision. Its summary argued that the benefit had not justified the cost at the graph’s then-small size. I have not independently recovered the original comparison’s full dataset and method for this chapter, so I am not turning its quoted size threshold or speed ratio into a general rule. What is supported here is that the prior result informed the decision to stop spending on an automatic rerun.
That was a change from the July 5 carry-forward instruction to rerun once the coercion fix landed. The later evidence changed what “unblocked” meant. The code path could be repaired while the operational choice remained unresolved. Another model or stricter generation route might produce better candidates, but that was a proposal to evaluate, not an outcome this chapter can claim.
What I would keep from the work
First, I would follow one failing document through the actual sequence before accelerating a component. A backlog is a symptom. Knowing that embedding is slow is useful, but the first failed boundary decides whether that speed is relevant to the current failure.
Second, I would test where an adaptation is attached. The first change’s helpers were not the whole feature. The factory or constructor that selected the live client was part of its correctness contract. A small construction-path assertion could catch a mistake that many detailed transformation tests would leave intact.
Third, I would keep structural recovery conservative. Adding a missing wrapper or decoding a supplied JSON string preserves information. Inventing entity names to make a response pass validation changes the evidence. When the response contains a description of a schema rather than an instance, rejection with an inspectable warning is more useful than a fabricated success.
Fourth, I would report output quality separately from exception handling. For a future controlled run, I would retain the document set, model and provider route, raw responses under the appropriate privacy boundary, input counts, rejection reasons, accepted candidates and reviewed examples. Then I could ask whether the resulting graph improved a task, rather than treat node growth alone as the objective.
Finally, I would make stopping a legitimate result. The defensive client change remained useful. The larger backfill still needed a reason to proceed. Keeping those decisions separate let the repair stand without committing more resources to output that the available probe did not justify trusting.
What comes next
The same July 6 handover describes another problem: previous decisions existed in memory, but the working process did not consistently consult them before searching again. That is the next thread I would follow: making existing evidence easier to find before repeating an expensive investigation. The retained record establishes the motivation and a proposed enforcement mechanism. It does not yet establish sustained adoption or a measured improvement in later work.
Glossary — plain-English definitions
Backfill
Processing an existing backlog so older documents enter a newer or repaired pipeline. Official reference.
Entity
A named thing extracted from text; being extracted does not prove it is correct. Official reference.
Entity resolution
Deciding whether a new mention matches something already known. Official reference.
JSON
A text format for values such as objects, lists, strings and numbers. Official reference.
Schema
A description of the structure and constraints expected of data. Official reference.
Schema echo
Here, output describing a data type instead of supplying an instance from the document. Official reference.
Coercion
Here, conservative normalisation such as adding a wrapper or decoding supplied JSON; it does not invent missing facts. Official reference.
Pydantic
A Python library for validating values against declared models, types and constraints. Official reference.
Response model
The software contract for a response; distinct from the language model that generates it. Official reference.
Subclass
A class extending another class; the repaired client keeps the parent call and adapts selected results. Official reference.
Active path
The code the application actually constructs and calls, rather than another available implementation. Official reference.
Synthetic probe
A deliberately supplied test input, useful for checking a mechanism but not a replay of historical traffic. Official reference.
Structured output
Generation constrained to a requested structure when supported and enforced by the chosen route. Official reference.
Embedding
A numerical representation used for similarity search; accelerating it does not repair an earlier extraction failure. Official reference.
Sources and reconstruction limits
The source inventory and claim ledger retained with this draft link the retained July 5 burst handover, its later diagnosis and the July 6 outcome report, along with the two historical implementations and their tests. The superseded handover remains unchanged as a comparison reference. Historical private source is inspectable in this review package; upstream project links above credit the public maintainers and explain the concepts without suggesting Rootweaver’s private code is public.
This selection covers the graph-extraction thread, not every activity on those days. Unrelated operational work, the separate memory-tool implementation and private projects are omitted. Complete original provider output, a reproducible historical probe bundle and the original graph-benefit benchmark are not available here. Those gaps limit the claims; they are not filled by current documentation, a reconstructed payload or a passing local check.