arango-query-core 0.2.5__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. arango_query_core-0.2.7/.claude/skills/pr-review/SKILL.md +156 -0
  2. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/CHANGELOG.md +47 -0
  3. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/PKG-INFO +2 -1
  4. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/README.md +1 -0
  5. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/__init__.py +2 -0
  6. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/mapping.py +30 -0
  7. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/__init__.py +5 -0
  8. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/engine.py +18 -3
  9. arango_query_core-0.2.7/arango_query_core/nl/host.py +223 -0
  10. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/postconditions.py +7 -5
  11. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/docs/PRD.md +2 -1
  12. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/pyproject.toml +1 -1
  13. arango_query_core-0.2.7/tests/fixtures/nl_system_prompts_0_2_6.json +12 -0
  14. arango_query_core-0.2.7/tests/test_mapping_property_encoding.py +74 -0
  15. arango_query_core-0.2.7/tests/test_nl_engine_host_extensions.py +247 -0
  16. arango_query_core-0.2.7/tests/test_nl_host.py +235 -0
  17. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_postconditions.py +25 -0
  18. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  19. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/.github/workflows/ci.yml +0 -0
  20. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/.github/workflows/publish.yml +0 -0
  21. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/.gitignore +0 -0
  22. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/LICENSE +0 -0
  23. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/aql.py +0 -0
  24. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/errors.py +0 -0
  25. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/exec.py +0 -0
  26. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/extensions.py +0 -0
  27. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/fewshot.py +0 -0
  28. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/grounding.py +0 -0
  29. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/pathindex.py +0 -0
  30. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/providers.py +0 -0
  31. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/seams.py +0 -0
  32. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/nl/synthbank.py +0 -0
  33. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/owl_rdflib.py +0 -0
  34. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/owl_turtle.py +0 -0
  35. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/arango_query_core/py.typed +0 -0
  36. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  37. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_mapping_helpers.py +0 -0
  38. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_mapping_resolver_normalization.py +0 -0
  39. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_mapping_supertypes.py +0 -0
  40. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_mapping_vci_detection.py +0 -0
  41. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_engine.py +0 -0
  42. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_engine_extraction_and_resilience.py +0 -0
  43. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_fewshot.py +0 -0
  44. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_grounding.py +0 -0
  45. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_pathindex.py +0 -0
  46. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_providers.py +0 -0
  47. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_nl_synthbank.py +0 -0
  48. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_owl_rdflib.py +0 -0
  49. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_owl_turtle_roundtrip.py +0 -0
  50. {arango_query_core-0.2.5 → arango_query_core-0.2.7}/tests/test_upstream_markers.py +0 -0
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: pr-review
3
+ description: Review an open arango-query-core pull request end to end — independently verify mergeability and the full CI gate (pytest, ruff, mypy) in an isolated worktree, trace the diff for correctness, and report a clear merge/no-merge recommendation. Use when asked to look at, review, or check a PR in this repo, or before merging one.
4
+ ---
5
+
6
+ # PR review (arango-query-core)
7
+
8
+ ## Invocation
9
+ `/pr-review <PR number>` — or just ask to look at / review a PR.
10
+
11
+ ## Purpose
12
+ Don't trust GitHub's green checkmark or a skim of the diff alone. Independently
13
+ rebuild the PR's test/lint/type gate in a scratch worktree, trace the tricky
14
+ logic by hand against the new tests, and only then recommend merging. This repo
15
+ has hit real, silent failure modes (see **Gotchas** below) that a surface-level
16
+ review misses.
17
+
18
+ ## Repo shape (read this first)
19
+ - **Primary repo:** `arango-solutions-org/arango-query-core` — PRs and issues
20
+ live here. **Secondary/mirror:** `ArthurKeen/arango-query-core` — code-only,
21
+ kept in sync by a dual-push `origin` remote; it does not carry its own PRs.
22
+ Always run `gh pr` / `gh issue` commands against the primary unless told
23
+ otherwise. (Confirm which is primary — it has changed before.)
24
+ - **Release gate:** PyPI publishing is pinned-artifact (CC-9): every merge to
25
+ `main` is a potential version bump. If the PR's `CHANGELOG.md` entry lands
26
+ under a version header that's already tagged and published, that's a bug in
27
+ the PR (or in a prior release) worth flagging, not something to wave through.
28
+
29
+ ## Protocol
30
+
31
+ ### 1. Pull the PR's shape
32
+ ```bash
33
+ gh pr view <N> --repo arango-solutions-org/arango-query-core \
34
+ --json title,body,author,headRefName,baseRefName,commits,additions,deletions,changedFiles,mergeable,statusCheckRollup
35
+ gh pr diff <N> --repo arango-solutions-org/arango-query-core
36
+ ```
37
+ Read the PR body as the author's own claim about scope and evidence (eval
38
+ numbers, measured deltas) — you're about to check whether the code backs it up.
39
+
40
+ ### 2. Independently verify mergeability
41
+ Don't trust the `mergeable` field alone — it can read `UNKNOWN` right after a
42
+ push, and (per the gotchas below) a corrupted commit graph can make GitHub
43
+ report `CONFLICTING` even when the content is actually fine, or vice versa.
44
+ Cross-check with a real history-aware merge:
45
+ ```bash
46
+ git fetch origin
47
+ git merge-tree --write-tree origin/main origin/<head-branch>
48
+ echo "exit: $?" # 0 and a single tree id = clean; conflict markers = real conflict
49
+ ```
50
+ Use the **modern 3-ref-aware form** (`--write-tree`), never the bare legacy
51
+ `git merge-tree <a> <b>` — it ignores actual commit ancestry and reports false
52
+ conflicts for a branch that already contains `main`.
53
+
54
+ ### 3. Rebuild the CI gate in an isolated worktree
55
+ Never run this against your own working tree (it may have unrelated local
56
+ changes, and installing a PR's deps into your main venv is how versions
57
+ drift). Mirror `.github/workflows/ci.yml` exactly:
58
+ ```bash
59
+ git worktree add /tmp/pr<N>-check origin/<head-branch> --detach
60
+ cd /tmp/pr<N>-check
61
+ python3 -m venv .venv-check && source .venv-check/bin/activate
62
+ pip install -q -e ".[dev]"
63
+
64
+ pytest -q # mirrors: pytest -q --tb=short
65
+ ruff check .
66
+ ruff format --check .
67
+ mypy # mirrors: mypy (files = ["arango_query_core"])
68
+ ```
69
+ All four must be clean. Note the test count and compare it to `main`'s —
70
+ a PR that doesn't grow the count for a new feature is under-tested.
71
+
72
+ Clean up when done:
73
+ ```bash
74
+ cd /Users/arthurkeen/code/arango-query-core
75
+ git worktree remove /tmp/pr<N>-check --force
76
+ ```
77
+
78
+ ### 4. Trace the diff, don't just skim it
79
+ For anything touching control flow (retry loops, fallback logic, ordering
80
+ rules), hand-trace at least the two or three trickiest test cases against the
81
+ actual code — don't assume the tests passing proves the stated semantics.
82
+ Specifically check:
83
+ - **Scope honesty** — does the PR body list what's *not* included? A PR that
84
+ quietly expands scope beyond its own description is a flag, not a bonus.
85
+ - **Protocol/seam changes** — `QueryLanguageAdapter` seams are called
86
+ *unconditionally* by `NLQueryEngine` (no `hasattr` guard). A new seam means
87
+ every existing `FakeAdapter` in the test suite needs a stub for it, or the
88
+ merged tree will crash at prompt-build time. Grep for adapter classes in
89
+ `tests/` and confirm each implements every current seam.
90
+ - **CHANGELOG placement** — does the entry land under a version header that's
91
+ already tagged (`git tag -l`) and already on PyPI? If so, the version is
92
+ effectively lying about its own contents (see Repo shape above) — flag it
93
+ even though it's not this PR's fault to fix.
94
+
95
+ ### 5. Report
96
+ State plainly: what the PR does, the hand-traced correctness notes, the
97
+ independent verification results (test count, ruff, mypy, merge-tree exit
98
+ code, CI check conclusions), any scope/changelog issues found, and an explicit
99
+ recommend-or-not. Don't hedge if everything checks out — say so and recommend
100
+ merging.
101
+
102
+ ### 6. If asked to merge
103
+ ```bash
104
+ gh pr merge <N> --repo arango-solutions-org/arango-query-core --merge --delete-branch
105
+ ```
106
+ Re-check `mergeable`/CI immediately before merging — state can change between
107
+ review and merge. After merging, **sync manually** — merging on GitHub does
108
+ not update your local clone or the mirror:
109
+ ```bash
110
+ git fetch origin && git checkout main && git merge --ff-only origin/main
111
+ git push origin main # dual-push: lands on both primary and mirror
112
+ ```
113
+ `--delete-branch` only deletes the branch on the repo the PR lives in
114
+ (primary). If the branch was ever pushed to the mirror too, delete it there
115
+ explicitly: `git push https://github.com/ArthurKeen/arango-query-core.git --delete <branch>`.
116
+
117
+ ## Gotchas (hard-won, don't re-learn these)
118
+
119
+ - **Never `git stash` mid-merge-conflict-resolution**, even for an unrelated
120
+ side-check (like "did this formatting issue pre-exist on main?"). Stashing
121
+ while `MERGE_HEAD` is set but conflicts are already resolved-and-staged
122
+ silently drops the merge state — the commit you make afterward looks fine
123
+ (correct file content) but has only **one parent**, so Git and GitHub don't
124
+ actually see it as containing `main`. Symptom: `mergeable` stays
125
+ `CONFLICTING` after a resolution that looks complete. Fix: check
126
+ `git log --format='%H %P' <commit>` — a merge commit must show two parents.
127
+ If it shows one, `git reset --hard` to before the merge and redo it cleanly
128
+ with no stash in between, then verify parent count *before* pushing.
129
+ - **A clean-looking commit can still get zero CI runs.** Twice now, a push
130
+ (once a corrupted single-parent "merge", once a plain version-bump commit
131
+ plus its tag) registered **no check-suite at all** — not even third-party
132
+ apps like GitGuardian. This isn't a code problem; it's GitHub event-delivery
133
+ flakiness. Verify with
134
+ `gh api repos/<repo>/commits/<sha>/check-suites --jq '.check_suites'`
135
+ before concluding anything is broken. A delete-and-recreate of a tag
136
+ sometimes forces redelivery; it isn't guaranteed to. Don't block a release
137
+ on it — fall back to the proven manual path (below) rather than debug
138
+ GitHub's webhook plumbing indefinitely.
139
+ - **PyPI publish fallback.** The `publish.yml` workflow uses trusted
140
+ publishing (OIDC, no token) gated to `github.repository ==
141
+ 'ArthurKeen/arango-query-core'` and only fires on a `v*` tag push. If it
142
+ doesn't fire (see above) or you need to publish immediately: build and
143
+ check locally, then upload with the token in the gitignored `.env`:
144
+ ```bash
145
+ python -m build --outdir <scratch>/dist
146
+ twine check <scratch>/dist/*
147
+ TWINE_USERNAME=__token__ TWINE_PASSWORD=$(grep '^PYPI_TOKEN=' .env | cut -d= -f2) \
148
+ twine upload --non-interactive <scratch>/dist/*
149
+ ```
150
+ If the workflow does fire later for a version already manually published,
151
+ it will just hit a harmless `400 File already exists` on the duplicate
152
+ filename — not a problem, and actually confirms trusted publishing works.
153
+ - **PyPI's project JSON can lag a few seconds after upload** — `info.version`
154
+ may show the previous release momentarily. Re-check, or check
155
+ `'<version>' in releases` / hit the version's own release-page URL directly,
156
+ before concluding an upload failed.
@@ -6,6 +6,53 @@ exact versions (contextual-data-fabric CC-9) and re-run their golden /
6
6
  eval suites on every bump — pre-1.0, **minor** bumps may break the API
7
7
  and this file must say so explicitly.
8
8
 
9
+ ## [0.2.7] - 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - **Host extensions** (`arango_query_core.nl.host`). A host whose engine
14
+ accepts more than the target language can now say so without rewriting
15
+ the adapter's grammar section. `HostExtensions` declares:
16
+ - `predicates`: `HostPredicate`, a predicate the host's engine accepts
17
+ (such as a registered text match), the `Class.property` names it may be
18
+ used on, what not to write instead, and where the engine refuses it;
19
+ - `rules`: the host's own limits that are not about one property, one
20
+ sentence each;
21
+ - `properties`: `PropertyFact`, per-property facts (`type`, `required`,
22
+ `orderable`, `description`; names follow `PropertyInfo`).
23
+
24
+ `host_prompt_section()` renders them as one section that adds to the
25
+ grammar and says that everything the grammar forbids stays forbidden.
26
+ Host prose is collapsed to one line, capped at 600 characters, and has
27
+ runs of `#` collapsed, so it cannot open a prompt section or carry the
28
+ `## Examples` cache marker. Names are validated when built. All four
29
+ names are exported from `arango_query_core.nl`.
30
+ - **`NLQueryEngine.host_extensions`**, `None` by default. The section goes
31
+ straight after the grammar section, inside the cacheable prefix, before
32
+ `## Examples`. With `None`, or a declaration that renders nothing, the
33
+ system prompt is byte-identical to 0.2.6's.
34
+
35
+ ### Fixed
36
+
37
+ - The engine comment and the `nl.postconditions` docstrings claimed that
38
+ postcondition prompt sections stay inside the cacheable prefix. They are
39
+ appended last, so when a `## Examples` section is present they fall after
40
+ `split_system_for_anthropic_cache`'s split. Comments only; behaviour is
41
+ unchanged.
42
+
43
+ ## [0.2.6] - 2026-10-29
44
+
45
+ ### Added
46
+
47
+ - **Temporal property encodings.** A property may declare how its temporal
48
+ values are stored, `"encoding": "epoch_ms"` (integer milliseconds since the
49
+ epoch) or `"iso8601"` (an ISO-8601 string), read into
50
+ `PropertyInfo.encoding` (empty when not declared) and carried by
51
+ `schema_summary()`. Any other value is refused with `INVALID_MAPPING`.
52
+ The vocabulary is `TEMPORAL_ENCODINGS`. It is a physical detail, like
53
+ `field`, so OWL/Turtle does not carry it. arango-cypher-py lowers temporal
54
+ accessors, functions and arithmetic from it (LDBC WP-41).
55
+
9
56
  ## [0.2.5] - 2026-10-26
10
57
 
11
58
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)
5
5
  Project-URL: Repository, https://github.com/ArthurKeen/arango-query-core
6
6
  Project-URL: Changelog, https://github.com/ArthurKeen/arango-query-core/blob/main/CHANGELOG.md
@@ -81,6 +81,7 @@ fabric pin one artifact instead of forking the same code three ways.
81
81
  | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25/dense few-shot retrieval, caller-supplied semantic postconditions, and the generate→validate→repair loop |
82
82
  | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails, entity/predicate/path grounding) |
83
83
  | `arango_query_core.nl.grounding` / `pathindex` | Entity, predicate, and relationship-path grounding retrieval (seams 6-8) — inject known IDs, schema conventions, and navigation hints into the prompt |
84
+ | `arango_query_core.nl.host` | `HostExtensions` — what the host's engine accepts beyond the target language (registered predicates, per-property facts, its own limits), rendered after the grammar section via `NLQueryEngine(host_extensions=...)` |
84
85
  | `arango_query_core.nl.postconditions` | Caller-supplied semantic invariants enforced through the same generate→validate→retry budget, failing closed on exhaustion |
85
86
  | `arango_query_core.nl.synthbank` | Ontology-agnostic query-shape template catalog for synthetic few-shot bank generation |
86
87
 
@@ -20,6 +20,7 @@ fabric pin one artifact instead of forking the same code three ways.
20
20
  | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25/dense few-shot retrieval, caller-supplied semantic postconditions, and the generate→validate→repair loop |
21
21
  | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails, entity/predicate/path grounding) |
22
22
  | `arango_query_core.nl.grounding` / `pathindex` | Entity, predicate, and relationship-path grounding retrieval (seams 6-8) — inject known IDs, schema conventions, and navigation hints into the prompt |
23
+ | `arango_query_core.nl.host` | `HostExtensions` — what the host's engine accepts beyond the target language (registered predicates, per-property facts, its own limits), rendered after the grammar section via `NLQueryEngine(host_extensions=...)` |
23
24
  | `arango_query_core.nl.postconditions` | Caller-supplied semantic invariants enforced through the same generate→validate→retry budget, failing closed on exhaustion |
24
25
  | `arango_query_core.nl.synthbank` | Ontology-agnostic query-shape template catalog for synthetic few-shot bank generation |
25
26
 
@@ -5,6 +5,7 @@ from .extensions import ExtensionPolicy, ExtensionRegistry
5
5
  from .mapping import (
6
6
  COLLECTION_NAME_RE,
7
7
  SUPERTYPE_STYLES,
8
+ TEMPORAL_ENCODINGS,
8
9
  IndexInfo,
9
10
  MappingBundle,
10
11
  MappingResolver,
@@ -25,6 +26,7 @@ except ImportError:
25
26
  __all__ = [
26
27
  "COLLECTION_NAME_RE",
27
28
  "SUPERTYPE_STYLES",
29
+ "TEMPORAL_ENCODINGS",
28
30
  "AqlExecutor",
29
31
  "AqlFragment",
30
32
  "AqlQuery",
@@ -163,6 +163,14 @@ class IndexInfo:
163
163
  traversal_safe: bool = True
164
164
 
165
165
 
166
+ #: Storage encodings of temporal property values.
167
+ #:
168
+ #: * ``epoch_ms`` — an integer, milliseconds since 1970-01-01T00:00:00Z.
169
+ #: * ``iso8601`` — an ISO-8601 string (``2010-01-03T15:10:31.499+00:00``,
170
+ #: or ``2010-01-03`` for a date).
171
+ TEMPORAL_ENCODINGS = frozenset({"epoch_ms", "iso8601"})
172
+
173
+
166
174
  @dataclass(frozen=True)
167
175
  class PropertyInfo:
168
176
  """Metadata for a single property/attribute on an entity or relationship.
@@ -187,6 +195,25 @@ class PropertyInfo:
187
195
  # / numeric / free_text / boolean / other), inferred by the schema analyzer.
188
196
  # Consumers choose a matching strategy from this rather than field names.
189
197
  role: str = ""
198
+ # How a temporal value (``type`` date / datetime / …) is stored: one of
199
+ # ``TEMPORAL_ENCODINGS``, or empty when the mapping does not say.
200
+ # Consumers must not guess an encoding: comparisons, components
201
+ # (``.year``) and arithmetic differ between the two.
202
+ encoding: str = ""
203
+
204
+
205
+ def _property_encoding(owner: str, name: str, raw: Any) -> str:
206
+ """A property's declared ``encoding``, checked against ``TEMPORAL_ENCODINGS``."""
207
+ if raw in (None, ""):
208
+ return ""
209
+ encoding = str(raw)
210
+ if encoding not in TEMPORAL_ENCODINGS:
211
+ raise CoreError(
212
+ f"Property {owner}.{name} declares encoding {encoding!r}; expected one of "
213
+ f"{', '.join(sorted(TEMPORAL_ENCODINGS))}",
214
+ code="INVALID_MAPPING",
215
+ )
216
+ return encoding
190
217
 
191
218
 
192
219
  @dataclass(frozen=True)
@@ -438,6 +465,7 @@ class MappingResolver:
438
465
  numeric_like=bool(meta.get("numericLike") or meta.get("numeric_like")),
439
466
  sample_values=tuple(str(s) for s in samples) if samples else (),
440
467
  role=str(meta.get("role") or ""),
468
+ encoding=_property_encoding(label_or_type, name, meta.get("encoding")),
441
469
  )
442
470
  elif isinstance(meta, str):
443
471
  result[name] = PropertyInfo(field=name, type=meta)
@@ -797,6 +825,7 @@ class MappingResolver:
797
825
  **({"numericLike": True} if p.numeric_like else {}),
798
826
  **({"sampleValues": list(p.sample_values)} if p.sample_values else {}),
799
827
  **({"role": p.role} if p.role else {}),
828
+ **({"encoding": p.encoding} if p.encoding else {}),
800
829
  }
801
830
  for name, p in props.items()
802
831
  },
@@ -831,6 +860,7 @@ class MappingResolver:
831
860
  **({"numericLike": True} if p.numeric_like else {}),
832
861
  **({"sampleValues": list(p.sample_values)} if p.sample_values else {}),
833
862
  **({"role": p.role} if p.role else {}),
863
+ **({"encoding": p.encoding} if p.encoding else {}),
834
864
  }
835
865
  for name, p in props.items()
836
866
  },
@@ -9,6 +9,7 @@ enters exclusively through :class:`~arango_query_core.nl.seams.QueryLanguageAdap
9
9
  from .engine import NLQueryEngine, NLResult
10
10
  from .fewshot import BM25Retriever, DenseRetriever, FewShotIndex, Retriever, cached_few_shot_index
11
11
  from .grounding import GroundedEntity, GroundedPredicate, LabelIndex, PredicateIndex
12
+ from .host import HostExtensions, HostPredicate, PropertyFact, host_prompt_section
12
13
  from .pathindex import ClassPath, ClassPathIndex
13
14
  from .postconditions import (
14
15
  Postcondition,
@@ -38,6 +39,8 @@ __all__ = [
38
39
  "GroundedEntity",
39
40
  "GroundedPredicate",
40
41
  "GuardrailVerdict",
42
+ "HostExtensions",
43
+ "HostPredicate",
41
44
  "LabelIndex",
42
45
  "LLMProvider",
43
46
  "NLQueryEngine",
@@ -48,6 +51,7 @@ __all__ = [
48
51
  "PostconditionContext",
49
52
  "PostconditionViolation",
50
53
  "PredicateIndex",
54
+ "PropertyFact",
51
55
  "QueryLanguageAdapter",
52
56
  "Retriever",
53
57
  "SHAPE_CATALOG",
@@ -55,6 +59,7 @@ __all__ = [
55
59
  "ValidationResult",
56
60
  "cached_few_shot_index",
57
61
  "get_llm_provider",
62
+ "host_prompt_section",
58
63
  "postcondition_prompt_sections",
59
64
  "run_postconditions",
60
65
  "split_system_for_anthropic_cache",
@@ -23,6 +23,7 @@ from collections.abc import Sequence
23
23
  from dataclasses import dataclass, field
24
24
  from typing import Any
25
25
 
26
+ from .host import HostExtensions, host_prompt_section
26
27
  from .postconditions import (
27
28
  Postcondition,
28
29
  PostconditionContext,
@@ -85,6 +86,11 @@ class NLQueryEngine:
85
86
  adapter's path index (seam 8) and rendered AFTER the predicate
86
87
  section — same post-cache-boundary discipline; the adapter resolves
87
88
  the anchor/target identifiers from its own seam-6/7 state (D-02).
89
+
90
+ ``host_extensions`` (:mod:`~arango_query_core.nl.host`) is rendered straight
91
+ after the grammar section, before ``## Examples``, so it is part of the
92
+ cacheable prefix. Caller postconditions' prompt sections come last; when a
93
+ ``## Examples`` section is present they therefore fall after the cache split.
88
94
  """
89
95
 
90
96
  provider: LLMProvider
@@ -103,6 +109,11 @@ class NLQueryEngine:
103
109
  on :attr:`NLResult.provider_errors`. If no attempt ever returned a
104
110
  response, the last exception is re-raised. False (the default) lets the
105
111
  first exception propagate, as before."""
112
+ host_extensions: HostExtensions | None = None
113
+ """What the host's engine accepts beyond the target language (:mod:`.host`),
114
+ rendered straight after the grammar section, inside the cacheable prefix.
115
+ ``None`` or a declaration that renders nothing leaves the system prompt
116
+ byte-identical."""
106
117
 
107
118
  def generate(self, question: str, *, schema_context: str = "", user_hint: str = "") -> NLResult:
108
119
  """Generate a query for *question*.
@@ -232,6 +243,9 @@ class NLQueryEngine:
232
243
 
233
244
  def _system_prompt(self, question: str, schema_context: str) -> str:
234
245
  sections = [self.adapter.grammar_prompt_section(schema_context)]
246
+ # Static per engine, so it sits with the grammar, before the ``## Examples``
247
+ # cache boundary. ``""`` when nothing is declared, and the join drops it.
248
+ sections.append(host_prompt_section(self.host_extensions))
235
249
  index = self.adapter.few_shot_index()
236
250
  if index is not None:
237
251
  examples = index.format_prompt_section(
@@ -255,9 +269,10 @@ class NLQueryEngine:
255
269
  if path_block:
256
270
  sections.append(path_block)
257
271
  # Caller postconditions announce their invariants up front so the model
258
- # aims to satisfy them, not just get corrected after breaking one. These
259
- # are static per call (no per-question content), so they stay inside the
260
- # cacheable prefix rather than after the few-shot cache boundary.
272
+ # aims to satisfy them, not just get corrected after breaking one. They are
273
+ # static per call but appended last: when an ``## Examples`` section is
274
+ # present, split_system_for_anthropic_cache splits there and these land in
275
+ # the uncached suffix; without one, the whole prompt is a single cached block.
261
276
  sections.extend(postcondition_prompt_sections(self.postconditions))
262
277
  return "\n\n".join(s for s in sections if s)
263
278
 
@@ -0,0 +1,223 @@
1
+ """What a host's engine accepts beyond the target language, told to the NL engine.
2
+
3
+ A host may run the generated query on an engine that accepts more than the language
4
+ the adapter teaches: a registered predicate such as a text match, per-property facts
5
+ (a datatype, whether it may be sorted, whether it is always present), and limits of
6
+ its own. Seam 1 (``grammar_prompt_section``) is adapter-owned and whole, so without
7
+ this module the only way to say any of that is to rewrite the grammar.
8
+
9
+ * :class:`HostPredicate` — a predicate the host's engine accepts in addition to the
10
+ language, with the properties it may be used on and what not to write instead.
11
+ * :class:`PropertyFact` — facts about one property that change which queries succeed.
12
+ Field names follow :class:`arango_query_core.mapping.PropertyInfo`.
13
+ * :class:`HostExtensions` — the declaration: predicates, rules (the host's limits that
14
+ are not about one property, one sentence each) and property facts.
15
+ * :func:`host_prompt_section` — the system-prompt section; ``""`` when nothing is
16
+ declared. :class:`~arango_query_core.nl.engine.NLQueryEngine` renders it straight
17
+ after the grammar section, inside the cacheable prefix, when its
18
+ ``host_extensions`` field is set.
19
+
20
+ Rules this module holds:
21
+
22
+ * **Nothing supplied, nothing changed.** ``None``, an empty declaration, a predicate
23
+ offered on no property, a fact that declares nothing and a blank rule all render
24
+ ``""``, and the engine's system prompt is then byte-identical to one built without
25
+ the field.
26
+ * **It adds to the grammar and never lifts its rules.** One fixed sentence,
27
+ :data:`HOST_RULE`, says so in the section itself.
28
+ * **Host strings cannot open a prompt section.** Every piece of host prose is
29
+ collapsed to one line, capped at :data:`LINE_MAX` characters, and has its runs of
30
+ ``#`` collapsed to one, so it can neither start a heading nor carry the
31
+ ``## Examples`` marker that
32
+ :func:`~arango_query_core.nl.providers.split_system_for_anthropic_cache` splits at.
33
+ * **Names are validated when built.** Predicate names, namespaces and
34
+ ``Class.property`` entries that are not plain names raise :class:`ValueError`, so
35
+ they never reach the prompt.
36
+
37
+ Checking that the model *used* a declared predicate is language-specific and stays
38
+ with the adapter or a host :class:`~arango_query_core.nl.postconditions.Postcondition`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from dataclasses import dataclass
45
+
46
+ from .grounding import _CONTROL_CHARS_RE, _WHITESPACE_RUN_RE
47
+
48
+ __all__ = [
49
+ "FACTS_HEADER",
50
+ "HOST_HEADER",
51
+ "HOST_RULE",
52
+ "LINE_MAX",
53
+ "RULES_HEADER",
54
+ "HostExtensions",
55
+ "HostPredicate",
56
+ "PropertyFact",
57
+ "host_prompt_section",
58
+ ]
59
+
60
+ HOST_HEADER = "## What this engine accepts beyond the rules above"
61
+ HOST_RULE = (
62
+ "These predicates are allowed in addition to the rules above, and only on the "
63
+ "properties listed for each. Everything the rules above forbid stays forbidden."
64
+ )
65
+ """The one fixed sentence: the section adds to the grammar, never overrides it."""
66
+ RULES_HEADER = "## Limits this engine adds to the rules above"
67
+ FACTS_HEADER = "## Facts about properties"
68
+
69
+ LINE_MAX = 600
70
+ """Cap on one piece of host prose. Host prose is a sentence or two, so the cap is
71
+ larger than a grounding label's (200), which cut a predicate's meaning short."""
72
+
73
+ _NAME_RE = re.compile(r"^[A-Za-z_][\w-]*(?::[A-Za-z_][\w-]*)?$")
74
+ """A plain or prefixed name, e.g. ``ex:textMatch``."""
75
+ _PROPERTY_RE = re.compile(r"^[A-Za-z_][\w-]*\.[A-Za-z_][\w-]*$")
76
+ """A ``Class.property`` name."""
77
+ _IRI_FORBIDDEN = frozenset('<>"{}|^`\\ \t\r\n')
78
+ _HASH_RUN_RE = re.compile(r"#{2,}")
79
+
80
+
81
+ def _clean(text: str) -> str:
82
+ """One line of host prose that cannot open a prompt section.
83
+
84
+ Control characters and whitespace runs collapse to one space (the rule
85
+ ``grounding._sanitize_label`` applies to labels), runs of ``#`` collapse to one
86
+ so no heading or ``## Examples`` marker survives, and the result is capped at
87
+ :data:`LINE_MAX`. Clean single-line prose without ``##`` passes through unchanged.
88
+ """
89
+ cleaned = _WHITESPACE_RUN_RE.sub(" ", _CONTROL_CHARS_RE.sub(" ", text))
90
+ return _HASH_RUN_RE.sub("#", cleaned).strip()[:LINE_MAX].rstrip()
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class HostPredicate:
95
+ """A predicate the host's engine accepts in addition to the language."""
96
+
97
+ name: str
98
+ """As the model writes it, e.g. ``ex:textMatch``."""
99
+ iri: str
100
+ """The namespace the name's prefix stands for, for the PREFIX line; ``""`` when the
101
+ name has no prefix."""
102
+ meaning: str
103
+ """One sentence, in the host's words."""
104
+ usage: str
105
+ """One example in the target language. It may span lines; each line is cleaned."""
106
+ applies_to: tuple[str, ...] = ()
107
+ """``Class.property`` names it may be used on. Empty means it is offered nowhere,
108
+ and a predicate offered nowhere is left out of the prompt."""
109
+ instead_of: str = ""
110
+ """What not to write instead, e.g. "an exact = comparison on these properties"."""
111
+ restrictions: tuple[str, ...] = ()
112
+ """Where the host's engine refuses it, one short clause each, e.g. "inside OPTIONAL"."""
113
+
114
+ def __post_init__(self) -> None:
115
+ if not _NAME_RE.match(self.name):
116
+ raise ValueError(f"host predicate name {self.name!r} is not a plain or prefixed name")
117
+ if ":" in self.name and not self.iri:
118
+ raise ValueError(f"host predicate {self.name!r} has a prefix but no namespace iri")
119
+ if any(ch in _IRI_FORBIDDEN for ch in self.iri):
120
+ raise ValueError(f"host predicate {self.name!r}: namespace iri {self.iri!r} is malformed")
121
+ bad = [p for p in self.applies_to if not _PROPERTY_RE.match(p)]
122
+ if bad:
123
+ raise ValueError(f"host predicate {self.name!r}: not Class.property names: {bad}")
124
+ if not _clean(self.meaning) or not _clean(self.usage):
125
+ raise ValueError(f"host predicate {self.name!r} needs a meaning and a usage example")
126
+
127
+ def prompt_lines(self) -> list[str]:
128
+ """The predicate's lines in the section."""
129
+ prefix = self.name.split(":", 1)[0] if ":" in self.name else ""
130
+ lines = [f"- {self.name} — {_clean(self.meaning)}"]
131
+ if prefix:
132
+ lines.append(f" Declare it with: PREFIX {prefix}: <{self.iri}>")
133
+ lines.append(f" Use it only on: {', '.join(self.applies_to)}")
134
+ instead_of = _clean(self.instead_of)
135
+ if instead_of:
136
+ lines.append(f" On those properties use it instead of {instead_of}.")
137
+ restrictions = [r for r in map(_clean, self.restrictions) if r]
138
+ if restrictions:
139
+ lines.append(" Refused: " + "; ".join(restrictions) + ".")
140
+ lines.append(" Example:")
141
+ lines += [f" {row}" for row in map(_clean, self.usage.splitlines()) if row]
142
+ return lines
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class PropertyFact:
147
+ """Facts about one property. ``None`` or ``""`` means "not declared" and renders nothing."""
148
+
149
+ entity: str
150
+ property: str
151
+ type: str = ""
152
+ """The value's datatype in the host's words, e.g. ``date``."""
153
+ required: bool | None = None
154
+ """Whether every entity has the property."""
155
+ description: str = ""
156
+ """What the property means."""
157
+ orderable: bool | None = None
158
+ """Whether the host's engine may sort or deduplicate on it."""
159
+
160
+ def __post_init__(self) -> None:
161
+ if not _PROPERTY_RE.match(f"{self.entity}.{self.property}"):
162
+ raise ValueError(f"property fact {self.entity!r}.{self.property!r} is not a Class.property name")
163
+
164
+ def line(self) -> str:
165
+ """The fact's line in the section; ``""`` when it declares nothing."""
166
+ parts = []
167
+ type_ = _clean(self.type)
168
+ if type_:
169
+ parts.append(f"type {type_}")
170
+ if self.required is not None:
171
+ parts.append("always present" if self.required else "may be missing")
172
+ if self.orderable is not None:
173
+ parts.append(
174
+ "may be sorted or deduplicated" if self.orderable else "may not be sorted or deduplicated"
175
+ )
176
+ description = _clean(self.description)
177
+ if description:
178
+ parts.append(description)
179
+ return f"- {self.entity}.{self.property}: " + "; ".join(parts) if parts else ""
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class HostExtensions:
184
+ """What the host's engine accepts beyond the target language."""
185
+
186
+ predicates: tuple[HostPredicate, ...] = ()
187
+ properties: tuple[PropertyFact, ...] = ()
188
+ rules: tuple[str, ...] = ()
189
+ """Limits the host's engine adds that are not about one property, one sentence each."""
190
+
191
+ @property
192
+ def empty(self) -> bool:
193
+ """True when nothing would render."""
194
+ return (
195
+ not any(p.applies_to for p in self.predicates)
196
+ and not any(f.line() for f in self.properties)
197
+ and not any(_clean(r) for r in self.rules)
198
+ )
199
+
200
+
201
+ def host_prompt_section(ext: HostExtensions | None) -> str:
202
+ """The system-prompt section for *ext*; ``""`` when it declares nothing that renders.
203
+
204
+ Blocks, each present only when it has content: the offered predicates under
205
+ :data:`HOST_HEADER` with :data:`HOST_RULE`, then the rules under
206
+ :data:`RULES_HEADER`, then the property facts under :data:`FACTS_HEADER`.
207
+ """
208
+ if ext is None or ext.empty:
209
+ return ""
210
+ blocks: list[str] = []
211
+ offered = [p for p in ext.predicates if p.applies_to]
212
+ if offered:
213
+ lines = [HOST_HEADER, HOST_RULE]
214
+ for pred in offered:
215
+ lines += pred.prompt_lines()
216
+ blocks.append("\n".join(lines))
217
+ rules = [f"- {r}" for r in map(_clean, ext.rules) if r]
218
+ if rules:
219
+ blocks.append("\n".join([RULES_HEADER, *rules]))
220
+ facts = [line for line in (f.line() for f in ext.properties) if line]
221
+ if facts:
222
+ blocks.append("\n".join([FACTS_HEADER, *facts]))
223
+ return "\n\n".join(blocks)
@@ -12,8 +12,8 @@ a required filter. No syntactic check sees it and no human sees it in a demo.
12
12
 
13
13
  A postcondition is a caller-supplied invariant. It rides the *same* retry
14
14
  budget as parse and translate: the model is told the rule up front (via
15
- :meth:`Postcondition.prompt_section`, rendered once into the cacheable system
16
- prefix), and a violation is fed back into the next prompt exactly like a
15
+ :meth:`Postcondition.prompt_section`, rendered once at the end of the system
16
+ prompt), and a violation is fed back into the next prompt exactly like a
17
17
  validation failure — so the loop gets a chance to *correct* the query rather
18
18
  than just reject it, and fails closed when the budget is spent — unless a
19
19
  candidate drew only a *soft* violation (``PostconditionViolation.soft``), in
@@ -167,9 +167,11 @@ def postcondition_prompt_sections(
167
167
  ) -> list[str]:
168
168
  """Collect non-empty ``prompt_section()`` output, in caller order.
169
169
 
170
- Rendered once into the cacheable system prefix by the engine, so a check
171
- that raises here (a broken ``prompt_section``) is skipped rather than
172
- aborting the whole generation.
170
+ Rendered once, at the end of the system prompt, by the engine. When the
171
+ prompt has an ``## Examples`` section the sections fall after
172
+ :func:`~arango_query_core.nl.providers.split_system_for_anthropic_cache`'s
173
+ split, in the uncached suffix. A check that raises here (a broken
174
+ ``prompt_section``) is skipped rather than aborting the whole generation.
173
175
  """
174
176
  if not postconditions:
175
177
  return []