Skip to content

fix(retain): drop the source-language rule when an output language is set - #3777

Open
feniix wants to merge 6 commits into
vectorize-io:mainfrom
feniix:fix/retain-output-language-contradiction
Open

fix(retain): drop the source-language rule when an output language is set#3777
feniix wants to merge 6 commits into
vectorize-io:mainfrom
feniix:fix/retain-output-language-contradiction

Conversation

@feniix

@feniix feniix commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #3776.

Problem

HINDSIGHT_API_LLM_OUTPUT_LANGUAGE is silently a no-op for retain. The extraction prompt hardcodes a "detect the input language and never translate" rule near the top, and output_language_directive() appends the opposite instruction at the end — the prompt contradicts itself.

The model resolves that in favour of the first rule: it lands ~6KB earlier (position 221 vs 6595 in the concise prompt) and is phrased far more forcefully — "STRICTLY FORBIDDEN from translating" against "Respond exclusively in X". Facts keep getting stored in the source language while /config reports the setting as active.

Consolidation already solves exactly this. build_consolidation_system_prompt() omits its default rule when a language is configured, consolidation/prompts.py:22-27 writes down the invariant ("the two must never both be present or they contradict each other"), and test_consolidation_directive_replaces_source_language_rule guards it. Retain never got the same treatment.

Fix

Hoist the rule into _DEFAULT_LANGUAGE_RULE and emit it through a {language_section} placeholder, deferred through the same two-stage .format() the mode templates already use for {retain_mission_section}.

All four extraction modes carried the rule (the base template feeds concise/custom/verbatim; verbose had its own copy) and all four are fixed:

mode unset → rule / directive set → rule / directive
concise ✅ present / absent ✅ absent / present
verbose ✅ present / absent ✅ absent / present
verbatim ✅ present / absent ✅ absent / present
custom ✅ present / absent ✅ absent / present

The rule is deliberately kept when no output language is set. It exists for #181 (CONCISE mode drifting to Japanese/Chinese on English/Italian input), so removing it outright would regress that. This makes the two mutually exclusive rather than deleting either.

The unset path is byte-identical to before this change in all four modes — verified by diffing the built prompts against main.

On the cacheable prefix: test_retain_cacheable_prefix_invariant_to_per_bank_freetext already documents output language as a low-cardinality structural toggle that may change the prefix and is correctly keyed by the cache fingerprint, so gating on it is in line with the existing invariant. High-cardinality free-text (mission, custom instructions) still cannot leak in — that test passes unchanged.

Tests

Two new parametrized tests over all four modes, mirroring the consolidation pair:

The existing retain tests only asserted the directive was present, never that the contradicting rule was absent — which is how this shipped.

tests/test_multilingual_bm25.py .......................................  31 passed

The two failures and six errors in tests/test_retain.py are pre-existing in this environment (they require HINDSIGHT_API_LLM_API_KEY); they fail identically on unmodified main.

Verification against a live deployment

Reproduced on 0.9.1 with provider openai-codex / gpt-5.6-luna and HINDSIGHT_API_LLM_OUTPUT_LANGUAGE=English.

Spanish input:

La decisión final fue que el proveedor openai-codex no soporta la API de lotes...

Before — stored fact still Spanish:

El proveedor openai-codex no admite la API de lotes, por lo que el equipo fijó la variable correspondiente en falso...

After:

The team determined that the openai-codex provider does not support the batch API and set the related feature flag to false; enabling it would break the retention process rather than accelerate it.

Russian and Chinese inputs likewise produced English only after the change. The llm_requests audit table confirmed both contradicting instructions were present in the same prompt beforehand.

Reflect had the same contradiction

The issue assumed reflect was fine. It was not — build_reflect_system_prompt() appended its source-language rule unconditionally and relied on in-prompt precedence, so a configured output language hit exactly the same silent no-op. It now goes through the shared helper too, and default_language_section()'s docstring records that all three pipelines call it: nothing may append a rule of its own without reintroducing the contradiction.

Scope

Narrowed at review request — this PR is now only the language fix: 5 commits over 6 files, all prompt assembly. The #3756 retain-memory follow-ups that were originally stacked here (content-hash derivation, the sub-batch splitter, the screening cache, _SubBatch.index, _flush(), the benchmark imports) have moved to #3787 and land on their own merits.

The reflect fix needed a second half — found by adding the LLM test

Adding the hs_llm_core counterpart to test_consolidation_output_language.py (which the
deterministic tests had no way to substitute for) showed that dropping the contradicting rule
did not actually change reflect's behaviour. With the rule gone and nothing in the prompt
disagreeing with the directive
, a Chinese question with the output language set to English
still came back in Chinese:

reflect final synthesis gemini-2.5-flash-lite gemini-2.5-flash
directive last in the system prompt (the first fix) 0/12 English 0/5 English
directive last in the user message (this PR) 12/12 English 5/5 English

build_final_system_prompt appended the directive at the end of the system message — but the
question and the retrieved data arrive after it, in the user message. "Last in the system prompt"
is not last. build_final_prompt and build_reduce_prompt now close the user message with it.
Both, because _forced_final_synthesis picks between them on whether the retrieved data fits one
chunk, and a setting that only worked below that threshold is the same silent no-op in a new
disguise. It also takes a per-bank toggle out of the cacheable system prefix.

Retain needed no such move — verified independently, its directive is followed from the system
prompt. The difference is the task: extraction rewrites its input, where the instruction is the
whole job; answering a question carries a strong prior to mirror the asker's language.

Tests

tests/test_retain_reflect_output_language.py — the behavioural half, mirroring
test_consolidation_output_language.py, which had no retain or reflect counterpart. Chinese
source material so "did it translate?" is a script test rather than a vocabulary guess; the judge
confirms the phrasing. Both tests drive the real pipeline: extract_facts_from_text for retain,
and for reflect the same system/user prompts and tool-less call _forced_final_synthesis makes.

Verified against gemini-2.5-flash-lite, the model the Core LLM job uses — 2 passed, four
consecutive runs. The deterministic prompt-assembly tests passed throughout the broken reflect
behaviour, which is precisely why this file exists.

@strix-security

strix-security Bot commented Aug 24, 2026

Copy link
Copy Markdown

Strix Security Review

Warning

This pull request has 13 commits after the last Strix review (067ed1e). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

No security issues found.

Updated for 067ed1e.


Reviewed by Strix
Re-run review · Configure security review settings

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Thanks for this — I verified the bug and the fix independently, and the fix itself is correct. Running the real _build_extraction_prompt_and_schema() on current main, all four extraction modes carry both the "STRICTLY FORBIDDEN from translating" rule and output_language_directive() at once; on this branch the rule is dropped when a language is set and kept when it isn't. Same for reflect, which turned out to have the same contradiction the issue thought it didn't. Good catch on that.

But could you please scope this PR down to only what's needed to fix #3776?

Right now it's 14 commits spanning two unrelated workstreams. Only these are the language fix:

  • 067ed1ef fix(retain): drop the source-language rule when an output language is set
  • 703eef9d refactor(prompts): select the language rule in one shared helper
  • 8ac098df refactor(retain): fill the extraction template from a scalar, not a dict
  • f4fdce56 fix(reflect): drop the source-language rule when an output language is set
  • 9af43568 test(reflect): update the language-override test to the corrected contract

The rest are #3756 retain-memory follow-ups — content-hash derivation, the sub-batch splitter, the screening cache, _SubBatch.index, _flush(), the benchmark imports — touching memory_engine.py, token_encoding.py, fact_storage.py, orchestrator.py and hindsight-dev/benchmarks/. That's ~160 lines of churn in the retain hot path that a reviewer of a prompt-assembly fix has no reason to be reading, and it makes the change much harder to reason about or revert on its own. Please move them to a separate PR; they can land on their own merits.

Two things I noticed that support splitting:

  1. sha256_windowed (token_encoding.py:79) arrives with no production caller — only tests import it, since derive_document_content_hash rolls its own digest loop. check-unused-code won't flag it (vulture is advisory, and it is referenced from tests). Either wire it up or drop it, in whichever PR carries it.

  2. prompt_utils.py:53-56 now contradicts the PR's own fix: "Reflect does NOT use this helper... so it still carries the contradiction described above." f4fdce56 made reflect use it (reflect/prompts.py:876). Worth correcting — it's exactly the comment that would send the next reader off to re-fix reflect.

One heads-up on CI: this is a fork PR, and test-api is gated on has_secrets == 'true' (.github/workflows/test.yml:1708), so all three shards plus Core LLM and LLM acceptance skipped. The green run only covers build-imports, check-unused-code and verify-generated-files — none of the new tests actually ran. We'll run the full suite on our side before merging.

@feniix

feniix commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Scoped down: the five commits you listed, rebased on current main. 6 files, +173/−50, all prompt assembly (prompt_utils.py, consolidation/prompts.py, reflect/prompts.py, retain/fact_extraction.py, two test files). memory_engine.py, token_encoding.py, fact_storage.py, orchestrator.py and hindsight-dev/ are out of the diff.

The #3756 follow-ups are #3787. Splitting them surfaced that 4ea0e005 straddled both workstreams, which is where the stale docstring came from.

sha256_windowed is fixed in #3787. It had no caller because derive_document_content_hash sanitizes each window before hashing it, so it couldn't call sha256_windowed(text) and ran its own digest loop instead. It now takes a per-window transform; retain passes _sanitize_text and drops the loop, other callers pass nothing. The transform has to be context-free or the digest becomes a function of TEXT_WINDOW_CHARS and stops matching rows already written, so there's a test hashing several windows through one against transforming the whole string first.

prompt_utils.py:53-56: that sentence came from 4ea0e005, which landed before f4fdce56 made reflect use the helper. 4ea0e005 went to #3787 with the rest of the hash work, and the docstring here now says all three pipelines call it. It's folded into the reflect commit rather than added on top.

Also expanded the PR description, which never covered the reflect half.

On CI: 55 passed locally here, 54 on #3787, ruff and ty clean. Not a substitute for the shards, so the full suite is still yours to run.

@feniix

feniix commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Added the hs_llm_core test for retain and reflect, mirroring test_consolidation_output_language.py.

It found that the reflect fix didn't work. With the contradicting rule gone and nothing left in the prompt disagreeing with the directive, a Chinese question with the output language set to English still came back in Chinese:

reflect final synthesis gemini-2.5-flash-lite gemini-2.5-flash
directive last in the system prompt (what I pushed) 0/12 English 0/5 English
directive last in the user message (now) 12/12 English 5/5 English

build_final_system_prompt appended the directive to the end of the system message, but the question and the retrieved data arrive after it in the user message. build_final_prompt and build_reduce_prompt now carry it instead. Both of them, because _forced_final_synthesis picks between the two on whether the retrieved data fits one chunk, so fixing only the first would leave the setting working below that threshold and not above it. It also takes a per-bank toggle out of the cacheable system prefix.

Retain is unaffected. I ran it separately rather than assuming: its directive is followed from the system prompt and the extraction test passes unchanged. I don't have an explanation for the difference that I've actually tested, so I've left one out.

Every deterministic test passed throughout, including the two I'd added — the prompt was correct and only its position was wrong. Four prompt-assembly tests encoded the old layout and are updated to pin the directive to the user prompt.

Verified on gemini-2.5-flash-lite, which is what the Core LLM job runs: 2 passed over four consecutive runs, plus the samples above. 412 passed / 7 skipped across the non-LLM reflect and prompt tests. Still needs your run of the shards.

Retain follow-ups and sha256_windowed are in #3787.

feniix and others added 6 commits August 25, 2026 10:54
… set (vectorize-io#3776)

`HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` was a silent no-op for fact extraction.
The extraction prompt hardcodes a "detect the input language and never
translate" rule near the top, and `output_language_directive()` appends the
opposite instruction at the end, so the prompt contradicted itself. The model
resolves that in favour of the first rule — it comes ~6KB earlier and is
phrased far more forcefully ("STRICTLY FORBIDDEN from translating" vs
"Respond exclusively in X") — and facts kept being stored in the source
language while the setting appeared to be in effect.

Consolidation already solves this: `build_consolidation_system_prompt()` omits
its default rule when a language is configured, and
`test_consolidation_directive_replaces_source_language_rule` guards it. Retain
never got the same treatment, and its tests only asserted the directive was
present, never that the contradicting rule was absent — which is how this
shipped.

Hoist the rule into `_DEFAULT_LANGUAGE_RULE` and emit it through a
`{language_section}` placeholder, deferred through the two-stage format the
mode templates already use for `{retain_mission_section}`. All four extraction
modes are affected and all four are fixed.

The rule is kept when no output language is set: it exists for vectorize-io#181 (CONCISE
mode drifting to Japanese/Chinese on English/Italian input), so this makes the
two mutually exclusive rather than removing it. The unset path is byte-identical
to before this change in every mode. Per the cacheable-prefix invariant in
`test_retain_cacheable_prefix_invariant_to_per_bank_freetext`, output language
is a low-cardinality structural toggle that may change the prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retain and consolidation each spelled out the same invariant — an explicit
output language drops the preserve-the-source-language rule instead of
arguing with it — as an inline `"" if language else ...` plus a paragraph of
comment explaining why. Two copies of a rule that must never diverge.

`default_language_section()` in prompt_utils now owns the selection and the
reasoning, next to `output_language_directive()`, the other half it excludes.
Each pipeline keeps its own wording of the rule; only the choice is shared.

Also flattens `_build_extraction_prompt_and_schema`: every template takes the
same two placeholders and only custom mode substitutes a third, so the modes
now differ in which constant they name rather than each repeating the
`.format()` call. The language tests assert against the rule constants instead
of quoting fragments of their text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mode_extras: dict[str, str]` only ever held one key, in one branch, and the
project standard is that a dict with a known key set should be a scalar or a
typed model. `str.format` ignores a keyword no template references, so passing
`custom_instructions` unconditionally is the same behaviour without asking the
reader which other keys can appear.

Prompt output is unchanged: all 27 variants (4 modes x 3 language settings x
custom-instructions on/off, plus consolidation) hash identically to 067ed1e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s set

Same defect vectorize-io#3776 fixed for retain, still live in the third pipeline.
`build_final_system_prompt` appended `_FINAL_LANGUAGE_RULE` unconditionally and
then `output_language_directive(...)`, so a prompt built with
HINDSIGHT_API_LLM_OUTPUT_LANGUAGE set told the model both to answer in the
question's language and to translate everything into the configured one.

The rule carries an escape hatch — "if a directive above specifies a response
language, follow the directive" — but the directive is appended at the very END
of the prompt, so "above" never matched it. The model followed the rule, which
comes first and is phrased more forcefully, and the setting was silently
no-opped for reflect answers.

Reflect now routes through `default_language_section()` like the other two, so
the mutual exclusion has one owner. The reasoning/agent prompt is untouched: it
never receives `llm_output_language` (only the final synthesis call does), so
its language rule has nothing to contradict.

Verified the new test fails against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tract

`test_final_prompt_output_language_override_is_appended_last` asserted the
override merely came *after* the default LANGUAGE rule, on the theory that
appending it last made it win. That is the theory the previous commit
disproved: the rule is phrased more forcefully and comes first, so the model
followed it and the configured language was a no-op.

With the rule now dropped when a language is set, `prompt.index("## LANGUAGE")`
raised ValueError. Renamed and rewritten to assert what actually has to hold —
the directive is present and the default rule is gone. Also renamed
`test_final_prompt_always_includes_language_rule`, which only ever covered the
no-output-language case, so "always" no longer overstates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…follow it

Dropping the contradicting source-language rule was necessary but not sufficient, and
only a real-LLM test could show it. With the rule gone and nothing in the prompt
disagreeing with it, a Chinese question with the output language set to English still
came back in Chinese 12 times out of 12 on gemini-2.5-flash-lite, and 5/5 on
gemini-2.5-flash.

The directive was appended to the end of `build_final_system_prompt`, which is last in
the *system* message — but the question and the retrieved data arrive after it, in the
user message. "Last in the system prompt" is not last, and the model answered in the
language of the text nearest the end. `build_final_prompt` and `build_reduce_prompt`
now close the user message with it instead: 12/12 and 5/5 English, same prompts
otherwise. Both, not just the first: `_forced_final_synthesis` picks between them on
whether the retrieved data fits one chunk, and a setting that only worked below that
threshold would be the same silent no-op wearing a different hat.

This also takes a per-bank toggle out of the cacheable system prefix, which can only
help the prefix cache.

Retain needed no such move — verified independently, its directive is followed from the
system prompt. The difference is the task: extraction rewrites the input, where the
instruction is the whole job; answering a question inherits a very strong prior to
mirror the asker's language.

Tests: `test_retain_reflect_output_language.py` drives the real synthesis call and judges
the answer, and is what caught this — the deterministic tests passed throughout, because
the prompt was correct and only its layout was wrong. The prompt-assembly tests now pin
the directive to the user prompt, including that it comes last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@feniix
feniix force-pushed the fix/retain-output-language-contradiction branch from 0573d1b to 34897e3 Compare August 25, 2026 13:58
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.

HINDSIGHT_API_LLM_OUTPUT_LANGUAGE is a silent no-op for retain: the extraction prompt contradicts itself

2 participants