Repository navigation
Conversation
A pure module builds the file v3's parameter search reads from a version's parameters, parameter nodes and current-law values: every parameter a reform can change (by the parameter tree's rule) with its path, stored label, unit, description and whether a draft can edit its current-law value; the labels of the other parameters under gov, which the search's concept clusters, aliases and member labels read; and every node under gov that has a stored label. Labels stay as stored (null when none), so the search's placeholder detection keeps working when the reader applies v1's fallback. Editability is stated for the earliest value plus the dates from which it changes, because a bracket that stops applying has a null value from that date; the file therefore never depends on the day it is built. The file is encoded as compact UTF-8 JSON, gzip-compressed with no timestamp so the same rows always give the same bytes, with the SHA-256 of the uncompressed JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new table parameter_search_files holds a catalog version's search file once per file format: the gzip-compressed JSON, the SHA-256 of the uncompressed JSON and both sizes. A later file format adds a row rather than changing a stored one, and a file is deleted with its version. The revision is autogenerated with no corrections and writes no rows; catalog publication writes the files. The Public API's runtime role may read the table and the catalog writer may read and insert rows. The parameter tree revision test now names its own revision instead of the head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Publication now builds, in its transaction and under its lock, the search file of each version it publishes from the installed packages' catalog, and inserts it if the version has none: each new version, and the installed version when an earlier release stored it before search files existed. Files are never built from rows an earlier release stored, so other stored versions are left without one. A stored file of the same format must equal the one built from the installed packages; publication fails if it differs or if a version it publishes has no file. The evidence lists each file written with its sizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rch-file The route reads the selected version's stored file of the current format with one indexed read and sends the stored gzip bytes unchanged to a client that accepts gzip, or the uncompressed JSON to any other. Each representation has a strong ETag from the SHA-256 of the uncompressed JSON, and a request whose If-None-Match names either gets 304. A named catalog version may be cached for a day and the running version for five minutes. A version without a file returns 503. The route reads its query through the shared strict catalog model, so unknown or repeated parameters and an unsupported country return 422, and OpenAPI declares the file's content schema and response headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v3's concept clusters pair each node's key with its label, and v1's metadata
labels the root node ("Government"), so the file now lists gov itself as well
as the labelled nodes under it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The builder now returns a ParameterSearchFileDocument TypedDict whose entry shapes a test compares with the OpenAPI content models, so the two cannot drift apart unnoticed. Nodes are read as the parameter tree's TreeNodeSource, and the per-value property is named editable throughout (EditabilityChange, is_editable_value) instead of draftable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… manifest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starlette's GZipMiddleware compressed whenever the text "gzip" appeared in Accept-Encoding, so a client refusing it (gzip;q=0) still received gzip; for the parameter search file, that gzip body even carried the uncompressed form's strong ETag. The application now uses NegotiatedGZipMiddleware, which weighs Accept-Encoding qualities and exact coding names, and the search file route decides its encoding with the same rule, so the encoding a route chooses is final. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A version stored before search files existed now returns 503 with the message the service writes for it, which names the version and says why it has no file, as the parameter tree route does; other catalog failures keep the general message. OpenAPI describes this 503 for the route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on roles Tests cover what the search file now shares with the other metadata routes and publication: browser scripts can read its ETag through CORS, with Vary naming Origin and Accept-Encoding; an unknown catalog version is a 404 without caching headers; publishing as the catalog writer role writes each version's file, which needs the role's declared INSERT grant; and one publication gives the installed version stored without them both its parameter tree and its search file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…angelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ormat 2 A version stored before catalog format 2 has no stored economy or household flags, so a search file built from the installed catalog's flags would contradict its own parameter rows. Publication now writes a file for an already-stored installed version only when its stored catalog format is 2, and does not require one from a version stored before; such a version keeps returning the 503 that names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request with If-None-Match now reads only the stored file's SHA-256 first; when it names the representation's ETag, the route class answers 304 without the file being loaded, and otherwise the file is read and sent as before. The metadata route helpers' ETag comparison is now the shared names_entity_tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file omits editable_changes when editability never changes and never writes null, so the OpenAPI model now declares it as an optional array with no null, and a test compares the builder's typed shapes with the models by JSON type and required keys as well as by field name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onders Tests now pin accepts_gzip's precedence (an explicit gzip entry decides over the wildcard either way, x-gzip counts as gzip), that a streamed body stays uncompressed when gzip is refused and is gzipped when it is accepted, and that Starlette still provides the two responders NegotiatedGZipMiddleware builds, with the arguments it passes. A unit test follows a value list with a null between two numbers through the editability changes, in date order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The search file route's 503 message, its OpenAPI description, the error's docstring and the changelog now state the rule instead of implying that republishing helps: only catalog format 2 versions that publication has published, or run with installed, since search files were added have a file. The fixed fragment also records that Accept-Encoding: * and differently cased values now receive gzip. The shared ETag description now says the tag hashes the uncompressed body, which the gzip form shares. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policyengine_api.content_encoding is now in the mypy file list and in the api.quality.mypy-v2 rule's enforced paths and scope, so CI's typecheck-v2 job checks it with untyped definitions disallowed; the rules document is regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The search file writer now reads a stored version's ID and catalog format with publication_reconciliation.stored_version, as the parameter tree writer does, instead of its own query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review at Verdict: correct and well tested. Two small points on the new gzip negotiation, and one operational consequence to write down. Stacked on #4046, so I reviewed only
Checked and fine: the gzip response sets What I ran: fresh clone, 🤖 Generated with Claude Code |
Fixes #4048
Summary
Plan item 1.18: one compact parameter search file per catalog version, served at
/v2/parameters/search-file, for the parameter search of the app's newer workspace (v3,app/src/libs/parameterSearch.ts). It is stacked on #4046 (1.17), and v3 does not use it until it moves to the v2 routes.{format, country_id, policyengine_version, parameters, other_parameters, nodes}.parameters: every parameter a reform can change, by 1.17's shared rule (parameter_tree.is_reform_changeable). Each entry haspath,label,unit,descriptionandeditable, and, only where it applies,editable_changes.editablesays whether a draft can edit the parameter's current-law value: a number, a yes/no value or an infinite threshold, as v3's draft editor allows. Where that changes over time (for example a bracket whose value becomes null from a date),editabledescribes the earliest value andeditable_changeslists{start_date, editable}for each date from which it changes. The file therefore does not depend on the day it was built.other_parameters: thepathandlabelof every other parameter undergov. v3's search reads these for its concept clusters, acronym aliases and member labels.nodes: thepathandlabelofgovand of every node under it that has a stored label, for breadcrumbs, schedule names and concept clusters.parameter_search_filestable holds one row per catalog version and file format (unique on both). Each row holds the gzip-compressed JSON (level 9, no timestamp, so the bytes are reproducible), the SHA-256 of the uncompressed JSON, and both sizes. Rows are deleted with their version.c614b6bcccfcis chained on3869eb814a90(1.17). It is autogenerated with no corrections, creates only the table and writes no rows.catalog_formatnull) get no file. Their parameter rows store no economy or household flags, so a file built from the installed catalog's flags would contradict them. They are exempt from the check above. The stored version's ID and format come from 1.17's sharedpublication_reconciliation.stored_version, so both writers apply this rule the same way.parameter_search_files_written(model, version, uncompressed and compressed bytes).GET /v2/parameters/search-file?country_id=…&policyengine_version=…(1.16's strict query model; the version is optional).status/resultenvelope.Content-Encoding: gzip. Any other client receives the uncompressed JSON."<sha256>", or"<sha256>-gzip"for the gzip one. A 304 needs the tag of the representation being requested, so a client that switches encoding gets a 200.CacheableMetadataRoute, soCache-Control(a day for a named version, five minutes for the running one),Vary: Origin, Accept-Encoding, the CORS exposure ofETagand the 304 are those of every metadata route.If-None-Matchfirst reads only the stored hash. When it names the current tag, the route answers 304 without reading the file.Route contract changes
New route:
/v2/parameters/search-file, with its OpenAPI responses (200 withETag,Cache-Control,VaryandContent-Encodingheaders; 304; 404, 405, 422, 500 and 503). The contract registry gains one request (93 in all).Every route: gzip follows
Accept-Encoding's qualities. Starlette'sGZipMiddlewarecompressed whenever the text "gzip" appeared anywhere inAccept-Encoding. The newcontent_encoding.NegotiatedGZipMiddlewarereplaces it application-wide, with the same 1,000-byte threshold and level 4, and decides withaccepts_gzip: an explicitgziporx-gzipentry decides; otherwise*does; a quality of 0 refuses. On every route:gzip;q=0,br, gzip;q=0.0) now receives an uncompressed response, where it used to receive gzip;Accept-Encoding: *,br, *and differently cased values such asGZIPnow receive gzip, where they used to receive an uncompressed response;gzipped, no longer receives gzip.The search-file route uses the same rule, so its chosen encoding is final and the middleware never re-encodes it. The middleware subclasses Starlette's
GZipResponderandIdentityResponder, which Starlette does not document; the locked Starlette is 1.0.0, and a unit test fails with a clear message if either class or its constructor signature changes.Measured
Locally, with the .py branch containing PolicyEngine/policyengine.py#701 (6.10.0) installed:
parameters(a reform can change them)editable_changesother_parametersnodesRoute (the US file in local Postgres, FastAPI test client, 15 requests each): gzip 200 in 24.2 ms median (the first request 118.9 ms); 304 revalidation in 9.7 ms median; uncompressed 200 in 25.2 ms median.
Publication. The production-scale publication test passes with this PR: 52.6 s, and 115.1 MiB (120,693,440 bytes) of additional publisher memory against its 256 MiB limit. Its evidence lists both files with the sizes above.
Against the plan's estimate. The plan estimated 0.72 MB compressed for the US (92,706 parameters and 29,310 node labels, measured from v1's metadata on 2026-10-21). The file is 0.87 MB compressed and 19.9 MB uncompressed (the plan's measurement: 12 MB). Without
other_parametersit is 0.77 MB compressed (774,574 bytes) and 19.0 MB uncompressed.other_parametersis kept: please confirm. v3's search reads labels of parameters a reform cannot change, for its concept clusters, acronym aliases and member labels, so leaving them out could change search results. If they are not needed, removing them saves 93,832 bytes compressed (0.09 MB).Staging times need a deploy.
v3's search on this file
v3's own search code (
parameterSearch.ts, unchanged) was run over its evaluation cases on three inputs, each a v1-shaped snapshot:/{country}/metadataas today (the reference);ga-hb10013 → 2,ga-sb4764 → 3,ok-sb21564 → 3,ut-hb2102 → 3ga-sb476,ok-sb2156andut-hb210as with v1 labels; alsomo-sb4585 → 1,ms-sb2869not found → 11,ny-s907716 → 18multnomah_pfa:name3 → 5No case found today is lost, and the evaluation case files are unchanged.
Labels: an open decision for a person
The file carries the catalog's stored labels, which are .py's. .py generates labels for parameters without a stored one (
utils/parameter_labels.py): bracket parameters become " (tier N amount)" and breakdown parameters " ()". v1's/{country}/metadatadoes not have these generated labels: for those parameters it uses the last part of the name with underscores as spaces, and "bracket N" for brackets. The rank changes in the "catalog labels" rows above come from these labels. This is the same question as 1.17's (#4046), and the options are the same:This PR keeps .py's labels.
Search files for other versions stored earlier: left out
Migration notes
metadataroute group; the route is served by FastAPI and reads Supabase (no change). Nothing moves off Flask.alembic upgrade head, then catalog publication, which writes the files, before the new release takes traffic.# noqa: E501is added, on the import ofdatabase_connectors.reads_parameter_search_filein the metadata services module: the dotted module path alone is longer than the line limit and cannot be wrapped. It matches the two imports beside it.rules/registry.tomladdspolicyengine_api/content_encoding.pyto the scope, statement and checked paths ofapi.quality.mypy-v2, andpyproject.tomladds it to mypy's file list, so the new middleware is type-checked with the v2 code.docs/generated/rules.mdis regenerated. A review fromPolicyEngine/core-developersis requested.Follow-up (not changed here)
The other metadata routes give their gzip and uncompressed responses one strong ETag, computed before compression. Strong ETags should differ between encodings. This PR only corrects the shared header description ("a hash of the uncompressed response body, so a gzip-encoded response carries the same tag"). Changing those routes is left for a follow-up.
Waits for
other_parameters, and on the migration for other stored versions (above).Testing
CI on this branch is cancelled at the user's request. The evidence is local. For each commit that changes tests, its changed test modules were run against its parent's code (failed first) and then at the commit (passed). Postgres modules ran on a database recreated at the checked-out code's head, with the managed roles' privileges applied.
016e0b171Build each catalog version's parameter search fileModuleNotFoundErrorforparameter_search_file69dfb12efStore each catalog version's parameter search file in its own table3869eb814a90, the import side-effect test's module list,KeyError: 'parameter_search_files'; lifecycle:UndefinedTablealembic checkcleandb9054597Write the parameter search file of each catalog version being publishedNoResultFound); a differing stored file did not raiseb40b572c3Serve a catalog version's parameter search file at /v2/parameters/search-fileImportErrorfor the content model and the unavailable error; Postgres: noETag, 422 instead of 503, no service read7eb8f5080Include the labelled gov node in the parameter search fileassert [] == [{'path': 'gov', 'label': 'Government'}]6f9ae2ea6Type the parameter search file and name its editability consistentlyImportErrorforEditabilityChange32566f316Describe when parameter search file rows are written in the privilege manifest5a1bb4d0aGzip a response only for a client that accepts gzipgzip;q=0,br, gzip;q=0.0andgzippedwere compressed;br, *andGZIPwere not; a large search file sent withgzip;q=0came back gzip32939ade8Name the catalog version in the search file route's 5034693c89edTest the search file route's CORS exposure and 404, and its publication rolesInsufficientPrivilege: permission denied for table parameter_search_fileswhen the grant is revoked and the declaration narrowedbd986d6afDescribe the gzip negotiation fix and the search file's 503 in the changelog76767f76eWrite no search file for an installed version stored before catalog format 2a5dd07f00Answer a search file revalidation from its stored hash alonec8dd1de00Declare editable_changes as an optional list that is never nulleditable_changes1d13cd0dfPin gzip negotiation precedence, streamed bodies and Starlette's respondersa974a5d5dSay plainly which catalog versions have a search file97393e3deType-check the gzip negotiation module with the v2 code0e6bd7f86Look up the stored catalog version through the shared helperAt every commit, ruff passes and the focused unit set passes (the search file, metadata route, metadata service, publication, application and gzip modules: 203 to 268 tests). From the second commit on, the Postgres publication and metadata route tests pass at every commit (50 to 62 tests).
On the final commit:
make testset (without coverage), under the locked .py 6.10.0 from the lock file: 3,737 passed.alembic current --check-heads:c614b6bcccfc (head);alembic check: no new operations..coveragefile or names a private repository.Review
Round 1 found two minor bugs.
gzip;q=0) still received gzip under the uncompressed form's ETag, because Starlette's middleware compressed the body. Fixed in5a1bb4d0a, which changes negotiation on every route (above).govnode ("Government"), which v3's concept clusters read. Fixed in7eb8f5080.Its quality findings led to the move onto 1.16's caching route class (in the route commit, during the rebase onto 1.16 and 1.17),
6f9ae2ea6,32566f316,32939ade8and4693c89ed.76767f76ethen applied 1.17's rule for versions stored before catalog format 2.Round 2 found no substantiated bugs. Its final-pass findings are fixed in:
a5dd07f00: the 304 reads only the hash;c8dd1de00:editable_changesis optional and never null, and the shape test compares types;1d13cd0df: tests for precedence, streamed bodies, Starlette's responders and an editability change in the middle of a value list;a974a5d5d: the 503 message, the changelog fragments and the shared ETag description;97393e3de: the mypy file list;0e6bd7f86: 1.17's shared stored-version helper.🤖 Generated with Claude Code