arango-query-core 0.2.2__tar.gz → 0.2.3__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 (43) hide show
  1. arango_query_core-0.2.3/.claude/settings.json +8 -0
  2. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/CHANGELOG.md +15 -0
  3. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/PKG-INFO +1 -1
  4. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/engine.py +21 -3
  5. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/postconditions.py +38 -9
  6. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/docs/PRD.md +1 -1
  7. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/pyproject.toml +1 -1
  8. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_postconditions.py +133 -1
  9. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  10. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/.github/workflows/ci.yml +0 -0
  11. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/.github/workflows/publish.yml +0 -0
  12. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/.gitignore +0 -0
  13. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/LICENSE +0 -0
  14. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/README.md +0 -0
  15. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/__init__.py +0 -0
  16. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/aql.py +0 -0
  17. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/errors.py +0 -0
  18. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/exec.py +0 -0
  19. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/extensions.py +0 -0
  20. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/mapping.py +0 -0
  21. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/__init__.py +0 -0
  22. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/fewshot.py +0 -0
  23. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/grounding.py +0 -0
  24. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/pathindex.py +0 -0
  25. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/providers.py +0 -0
  26. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/seams.py +0 -0
  27. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/nl/synthbank.py +0 -0
  28. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/owl_rdflib.py +0 -0
  29. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/owl_turtle.py +0 -0
  30. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/arango_query_core/py.typed +0 -0
  31. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  32. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_mapping_helpers.py +0 -0
  33. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_mapping_resolver_normalization.py +0 -0
  34. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_mapping_vci_detection.py +0 -0
  35. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_engine.py +0 -0
  36. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_fewshot.py +0 -0
  37. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_grounding.py +0 -0
  38. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_pathindex.py +0 -0
  39. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_providers.py +0 -0
  40. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_nl_synthbank.py +0 -0
  41. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_owl_rdflib.py +0 -0
  42. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_owl_turtle_roundtrip.py +0 -0
  43. {arango_query_core-0.2.2 → arango_query_core-0.2.3}/tests/test_upstream_markers.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(gh run *)",
5
+ "Bash(git worktree *)"
6
+ ]
7
+ }
8
+ }
@@ -6,6 +6,21 @@ 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.3] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - `PostconditionViolation.soft` (default `False`): a soft violation is
14
+ retried like any other, but when the retry budget is spent
15
+ `NLQueryEngine.generate` returns the first valid candidate whose only
16
+ objection was soft, instead of failing closed. The candidate still passes
17
+ guardrails, and the violation stays on `NLResult.postcondition` (`ok` True
18
+ with a non-None `postcondition` marks a soft fallback). For checks such as
19
+ "the query returned no rows", where empty is sometimes the right answer.
20
+ `run_postconditions` now returns the first *hard* violation ahead of any
21
+ soft one, so a hard check listed after a soft one still applies. Additive;
22
+ existing checks are unaffected. Part of #4.
23
+
9
24
  ## [0.2.2] - 2026-10-21
10
25
 
11
26
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -39,7 +39,10 @@ class NLResult:
39
39
  """Outcome of one NL→query generation.
40
40
 
41
41
  ``query`` is empty when generation failed (``ok`` False); ``error``
42
- then carries the last validation failure or guardrail refusal.
42
+ then carries the last validation failure, postcondition violation or
43
+ guardrail refusal. ``ok`` True with a non-None ``postcondition`` means a
44
+ soft fallback: the retry budget ran out and ``query`` is the first valid
45
+ candidate whose only objection was soft (``PostconditionViolation.soft``).
43
46
  Token fields accumulate across retries so cost accounting sees the
44
47
  whole conversation, not the last attempt.
45
48
  """
@@ -96,6 +99,9 @@ class NLQueryEngine:
96
99
  failure: ValidationResult | None = None
97
100
  violation: PostconditionViolation | None = None
98
101
  candidate = ""
102
+ # First valid candidate whose only objection was soft: what the call
103
+ # returns if the budget runs out without a clean candidate.
104
+ fallback: tuple[str, ValidationResult, PostconditionViolation] | None = None
99
105
 
100
106
  for attempt in range(self.max_retries + 1):
101
107
  if attempt > 0:
@@ -138,13 +144,25 @@ class NLQueryEngine:
138
144
  result.postcondition = violation
139
145
  if violation is None:
140
146
  break
147
+ # Soft only if no check objected as hard (run_postconditions
148
+ # returns a hard violation ahead of any soft one).
149
+ if violation.soft and fallback is None:
150
+ fallback = (candidate, failure, violation)
151
+ else:
152
+ # Budget spent without a clean candidate. If one drew only a soft
153
+ # objection, return it — with the objection on the result — rather
154
+ # than refuse a query that may well be right.
155
+ if fallback is not None:
156
+ candidate, result.validation, result.postcondition = fallback
157
+ failure, violation = result.validation, None
141
158
 
142
159
  if failure is None or not failure.ok:
143
160
  result.error = failure.error if failure else "provider returned nothing"
144
161
  return result
145
162
  if violation is not None:
146
- # Budget spent on a valid-but-semantically-rejected query — fail
147
- # closed rather than return a plausible wrong answer.
163
+ # Budget spent on a valid-but-semantically-rejected query with no
164
+ # soft-only candidate to fall back on — fail closed rather than
165
+ # return a plausible wrong answer.
148
166
  result.error = f"{violation.reason} ({violation.code})"
149
167
  return result
150
168
 
@@ -15,14 +15,19 @@ budget as parse and translate: the model is told the rule up front (via
15
15
  :meth:`Postcondition.prompt_section`, rendered once into the cacheable system
16
16
  prefix), 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
- than just reject it, and fails closed when the budget is spent.
18
+ than just reject it, and fails closed when the budget is spent — unless a
19
+ candidate drew only a *soft* violation (``PostconditionViolation.soft``), in
20
+ which case the first such candidate is returned instead.
19
21
 
20
22
  Semantics (mirrors ``nl2cypher``'s original, language-agnostic here):
21
23
 
22
24
  * Postconditions run **after** the adapter's ``validate`` succeeds — a retry is
23
25
  only ever spent on an already-valid query.
24
- * The **first** violation wins; checks are not accumulated. One clear, specific
25
- correction beats a pile of them.
26
+ * The **first hard** violation wins; checks are not accumulated. One clear,
27
+ specific correction beats a pile of them. A *soft* violation does not stop
28
+ the run: the remaining checks still see the query, and any hard violation
29
+ among them takes precedence. Only a query no check objects to as hard can
30
+ become a soft fallback, whatever order the checks are listed in.
26
31
  * A check that **raises** is treated as a violation, not an error — a broken
27
32
  postcondition must not turn a safety mechanism into an outage. Fail closed.
28
33
  * :meth:`Postcondition.prompt_section` renders **once**; it must not vary
@@ -60,6 +65,19 @@ class PostconditionViolation:
60
65
  """How to fix it. Also fed into the retry prompt, after ``reason``."""
61
66
  code: str = "postcondition_violation"
62
67
  """Stable identifier for telemetry / assertions, e.g. ``"time_windowed"``."""
68
+ soft: bool = False
69
+ """A soft violation asks for a retry but does not make the query unusable.
70
+
71
+ Use it for an objection that is sometimes the right answer — e.g. "the query
72
+ returned no rows" when the honest answer may be "none". When the retry
73
+ budget is spent, the engine returns the first valid candidate whose only
74
+ objection was soft (with this violation on ``NLResult.postcondition``)
75
+ instead of failing closed. Hard violations (the default) still fail closed.
76
+
77
+ Only that first soft candidate is offered to the guardrails: if they refuse
78
+ it, the call fails closed with the guardrail's reason, even when a later
79
+ soft candidate would have passed — the conservative reading.
80
+ """
63
81
 
64
82
 
65
83
  @dataclass(frozen=True)
@@ -109,15 +127,22 @@ def run_postconditions(
109
127
  postconditions: Sequence[Postcondition] | None,
110
128
  context: PostconditionContext,
111
129
  ) -> PostconditionViolation | None:
112
- """Run *postconditions* in order, returning the first violation.
130
+ """Run *postconditions* in order, returning the first hard violation, or
131
+ else the first soft one.
132
+
133
+ A soft violation does not stop the run, so a hard check listed after it
134
+ still sees the query; otherwise a query breaking a hard rule could be
135
+ returned as a soft fallback.
113
136
 
114
- A check that raises is treated as a violation rather than being allowed to
115
- abort the translation: a broken check must not be able to turn a safety
116
- mechanism into an outage, and failing closed is the conservative reading.
137
+ A check that raises is treated as a (hard) violation rather than being
138
+ allowed to abort the translation: a broken check must not be able to turn a
139
+ safety mechanism into an outage, and failing closed is the conservative
140
+ reading.
117
141
  """
118
142
  if not postconditions:
119
143
  return None
120
144
 
145
+ first_soft: PostconditionViolation | None = None
121
146
  for pc in postconditions:
122
147
  code = getattr(pc, "code", pc.__class__.__name__)
123
148
  try:
@@ -128,9 +153,13 @@ def run_postconditions(
128
153
  reason=f"Postcondition {code!r} raised {type(exc).__name__}: {exc}",
129
154
  suggested_hint="Simplify the query; the check could not evaluate it.",
130
155
  )
131
- if violation is not None:
156
+ if violation is None:
157
+ continue
158
+ if not violation.soft:
132
159
  return violation
133
- return None
160
+ if first_soft is None:
161
+ first_soft = violation
162
+ return first_soft
134
163
 
135
164
 
136
165
  def postcondition_prompt_sections(
@@ -127,7 +127,7 @@ to prevent a three-way fork.
127
127
  | `nl.fewshot` | `FewShotIndex` over `(question, query)` pairs (legacy corpus keys accepted); `BM25Retriever` (rank_bm25, `nl` extra) and `DenseRetriever` (sentence-transformers, `dense` extra), selected via `mode="bm25"\|"dense"\|"auto"`; `cached_few_shot_index` memoizes the expensive dense build |
128
128
  | `nl.grounding` | Seam 6/7 retrieval: `LabelIndex`/`GroundedEntity` (entity grounding) and `PredicateIndex`/`GroundedPredicate` (predicate/schema-convention grounding, renders IRI local names not labels), sharing one token-substring scorer |
129
129
  | `nl.pathindex` | Seam 8: `ClassPathIndex`/`ClassPath` — bounded shortest predicate paths between two seam-6/7-anchored classes over the TBox's class-connectivity graph |
130
- | `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion |
130
+ | `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion, unless a valid candidate drew only a soft violation (`PostconditionViolation.soft`); the first such candidate is returned, with the violation on `NLResult.postcondition` |
131
131
  | `nl.synthbank` | `SHAPE_CATALOG`/`ShapeTemplate` — 9 ontology-agnostic compositional query-shape templates for synthetic few-shot bank generation |
132
132
  | `nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams: (1) grammar prompt section, (2) few-shot corpus, (3) syntax validator, (4) repair rules keyed on validator errors, (5) guardrail checks, (6) entity grounding index, (7) predicate grounding index, (8) relationship-path grounding index. Plus `ValidationResult`, `GuardrailVerdict` |
133
133
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arango-query-core"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -4,7 +4,9 @@ Covers the semantics the feature promises: postconditions run only on valid
4
4
  queries, share the retry budget, feed their correction back into the next
5
5
  prompt, fail closed on exhaustion, first-violation-wins, a raising check is a
6
6
  violation (not an outage), the rule is announced once in the system prompt,
7
- and the guardrail gate still runs after. All with fakes — no network.
7
+ and the guardrail gate still runs after. Soft violations: retried the same way,
8
+ returned as a fallback on exhaustion, never ahead of a hard violation whatever
9
+ the check order. All with fakes — no network.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -208,3 +210,133 @@ def test_run_postconditions_helper_empty_is_none():
208
210
  ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
209
211
  assert run_postconditions("SELECT 1", None, ctx) is None
210
212
  assert run_postconditions("SELECT 1", [], ctx) is None
213
+
214
+
215
+ class NonEmpty:
216
+ """Soft stand-in for "the query returned no rows": a candidate without
217
+ 'WHERE' is treated as empty. Sometimes empty is the right answer, so the
218
+ objection is soft."""
219
+
220
+ code = "non_empty"
221
+
222
+ def prompt_section(self) -> str:
223
+ return ""
224
+
225
+ def check(self, query: str, *, context: PostconditionContext):
226
+ if "WHERE" in query:
227
+ return None
228
+ return PostconditionViolation(
229
+ code=self.code,
230
+ reason="The query returned no rows.",
231
+ suggested_hint="Check literals and labels.",
232
+ soft=True,
233
+ )
234
+
235
+
236
+ def test_soft_violation_is_retried_like_a_hard_one():
237
+ eng = _engine(["SELECT *", "SELECT * WHERE {}"], [NonEmpty()])
238
+ r = eng.generate("q")
239
+ assert r.ok is True and r.retries == 1
240
+ assert r.query == "SELECT * WHERE {}"
241
+ assert r.postcondition is None
242
+ assert "Check literals and labels." in eng.provider.calls[1][1]
243
+
244
+
245
+ def test_soft_exhaustion_returns_first_soft_candidate():
246
+ eng = _engine(["SELECT 1", "SELECT 2", "SELECT 3"], [NonEmpty()], max_retries=2)
247
+ r = eng.generate("q")
248
+ assert r.ok is True and r.error == ""
249
+ assert r.retries == 2 # the whole budget was spent first
250
+ assert r.query == "SELECT 1" # the first candidate, not the last
251
+ assert r.postcondition is not None and r.postcondition.soft
252
+ assert r.postcondition.code == "non_empty"
253
+
254
+
255
+ def test_soft_fallback_survives_a_later_invalid_candidate():
256
+ eng = _engine(["SELECT 1", "nonsense"], [NonEmpty()], max_retries=1)
257
+ r = eng.generate("q")
258
+ assert r.ok is True and r.query == "SELECT 1"
259
+ assert r.validation is not None and r.validation.ok
260
+
261
+
262
+ def test_soft_fallback_survives_a_later_hard_violation():
263
+ # Soft-only first candidate (has LIMIT, no WHERE); the second breaks the
264
+ # hard invariant. The soft candidate is the one worth returning.
265
+ eng = _engine(["SELECT * LIMIT 1", "SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=1)
266
+ r = eng.generate("q")
267
+ assert r.ok is True and r.query == "SELECT * LIMIT 1"
268
+ assert r.postcondition is not None and r.postcondition.code == "non_empty"
269
+
270
+
271
+ def test_hard_violation_still_fails_closed_beside_a_soft_check():
272
+ # Every candidate breaks the hard rule first (first violation wins), so
273
+ # none is ever soft-only: no fallback, fail closed as before.
274
+ eng = _engine(["SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=2)
275
+ r = eng.generate("q")
276
+ assert r.ok is False and r.query == ""
277
+ assert r.postcondition is not None and r.postcondition.code == "needs_limit"
278
+
279
+
280
+ def test_soft_fallback_still_passes_the_guardrail_gate():
281
+ eng = _engine(["SELECT 1"], [NonEmpty()], allow=False, max_retries=1)
282
+ r = eng.generate("q")
283
+ assert r.ok is False and r.query == ""
284
+ assert "tenant scope" in r.error
285
+
286
+
287
+ def test_violation_is_hard_by_default():
288
+ assert PostconditionViolation(reason="r", suggested_hint="h").soft is False
289
+
290
+
291
+ def test_hard_check_after_a_soft_one_still_applies():
292
+ # Regression: the soft check used to short-circuit the run, so a query
293
+ # breaking the hard rule listed after it came back ok as a soft fallback.
294
+ eng = _engine(["SELECT *"], [NonEmpty(), NeedsLimit()], max_retries=2)
295
+ r = eng.generate("q")
296
+ assert r.ok is False and r.query == ""
297
+ assert r.postcondition is not None and r.postcondition.code == "needs_limit"
298
+
299
+
300
+ def test_soft_then_hard_reports_the_hard_correction():
301
+ eng = _engine(["SELECT *", "SELECT * LIMIT 1 WHERE {}"], [NonEmpty(), NeedsLimit()])
302
+ r = eng.generate("q")
303
+ assert r.ok is True and r.retries == 1
304
+ assert "Add LIMIT 100." in eng.provider.calls[1][1] # the hard hint, not the soft one
305
+
306
+
307
+ def test_raising_check_after_a_soft_one_still_fails_closed():
308
+ class Boom:
309
+ code = "boom"
310
+
311
+ def prompt_section(self):
312
+ return ""
313
+
314
+ def check(self, query, *, context):
315
+ raise ValueError("kaboom")
316
+
317
+ eng = _engine(["SELECT 1"], [NonEmpty(), Boom()], max_retries=1)
318
+ r = eng.generate("q")
319
+ assert r.ok is False and r.query == ""
320
+ assert r.postcondition is not None and r.postcondition.code == "boom_error"
321
+
322
+
323
+ def test_soft_fallback_with_no_retries():
324
+ eng = _engine(["SELECT 1"], [NonEmpty()], max_retries=0)
325
+ r = eng.generate("q")
326
+ assert r.ok is True and r.retries == 0 and r.query == "SELECT 1"
327
+ assert r.postcondition is not None and r.postcondition.soft
328
+
329
+
330
+ def test_clean_last_candidate_beats_the_soft_fallback():
331
+ eng = _engine(["SELECT 1 LIMIT 1", "SELECT * LIMIT 1 WHERE {}"], [NeedsLimit(), NonEmpty()])
332
+ r = eng.generate("q")
333
+ assert r.ok is True and r.query == "SELECT * LIMIT 1 WHERE {}"
334
+ assert r.postcondition is None
335
+
336
+
337
+ def test_run_postconditions_prefers_hard_over_earlier_soft():
338
+ ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
339
+ v = run_postconditions("SELECT *", [NonEmpty(), NeedsLimit()], ctx)
340
+ assert v is not None and v.code == "needs_limit" and not v.soft
341
+ v = run_postconditions("SELECT * LIMIT 1", [NonEmpty(), NeedsLimit()], ctx)
342
+ assert v is not None and v.code == "non_empty" and v.soft