arango-query-core 0.2.8__tar.gz → 0.2.9__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 (52) hide show
  1. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/CHANGELOG.md +18 -0
  2. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/PKG-INFO +1 -1
  3. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/grounding.py +33 -3
  4. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/pyproject.toml +1 -1
  5. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_grounding.py +75 -0
  6. arango_query_core-0.2.8/.claude/settings.json +0 -8
  7. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/.claude/skills/pr-review/SKILL.md +0 -0
  8. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  9. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/.github/workflows/ci.yml +0 -0
  10. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/.github/workflows/publish.yml +0 -0
  11. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/.gitignore +0 -0
  12. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/LICENSE +0 -0
  13. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/README.md +0 -0
  14. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/__init__.py +0 -0
  15. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/aql.py +0 -0
  16. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/errors.py +0 -0
  17. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/exec.py +0 -0
  18. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/extensions.py +0 -0
  19. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/mapping.py +0 -0
  20. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/__init__.py +0 -0
  21. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/engine.py +0 -0
  22. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/fewshot.py +0 -0
  23. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/host.py +0 -0
  24. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/pathindex.py +0 -0
  25. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/postconditions.py +0 -0
  26. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/providers.py +0 -0
  27. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/seams.py +0 -0
  28. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/nl/synthbank.py +0 -0
  29. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/owl_rdflib.py +0 -0
  30. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/owl_turtle.py +0 -0
  31. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/arango_query_core/py.typed +0 -0
  32. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/docs/PRD.md +0 -0
  33. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  34. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/fixtures/nl_system_prompts_0_2_6.json +0 -0
  35. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_mapping_helpers.py +0 -0
  36. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_mapping_property_encoding.py +0 -0
  37. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_mapping_resolver_normalization.py +0 -0
  38. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_mapping_supertypes.py +0 -0
  39. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_mapping_vci_detection.py +0 -0
  40. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_engine.py +0 -0
  41. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_engine_extraction_and_resilience.py +0 -0
  42. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_engine_host_extensions.py +0 -0
  43. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_engine_request_context.py +0 -0
  44. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_fewshot.py +0 -0
  45. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_host.py +0 -0
  46. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_pathindex.py +0 -0
  47. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_postconditions.py +0 -0
  48. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_providers.py +0 -0
  49. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_nl_synthbank.py +0 -0
  50. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_owl_rdflib.py +0 -0
  51. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_owl_turtle_roundtrip.py +0 -0
  52. {arango_query_core-0.2.8 → arango_query_core-0.2.9}/tests/test_upstream_markers.py +0 -0
@@ -6,6 +6,24 @@ 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.9] - 2026-11-12
10
+
11
+ ### Fixed
12
+
13
+ - **Grounding finds a name told apart by a short token.** The scorer shared by
14
+ `LabelIndex` and `PredicateIndex` dropped every token under three
15
+ characters, so "HP Inc.", "GE Co.", "3M Co.", "AT&T Inc." or the "31" of
16
+ "Account Name 31" among "Account Name 1" to "200" was never retrieved, even
17
+ when the question gave the exact stored name. A token under three characters
18
+ now counts when the question holds it as a whole word, never as a substring
19
+ ("GE" is not found in "manager"). Before short words are read, possessive and
20
+ contraction endings are removed ("Media's", "don't"), accented words stay
21
+ whole ("Société" yields no "t"), a repeated short token counts once ("J&J"),
22
+ and one- and two-letter English words ("of", "at", "ok") are not counted.
23
+ Tokens of three or more characters match exactly as before. Retrieval can
24
+ only gain results; an item that matched before keeps its long-token hits, so
25
+ its rank changes only against items that now also match a short token.
26
+
9
27
  ## [0.2.7] - 2026-11-06
10
28
 
11
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.8
3
+ Version: 0.2.9
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
@@ -54,6 +54,29 @@ _STOP = frozenset(
54
54
  "please list show find get".split()
55
55
  )
56
56
 
57
+ #: A token shorter than this matches only as a whole word of the other side,
58
+ #: never as a substring: "ge" is inside "manager", but "GE" names a company.
59
+ _SHORT = 3
60
+
61
+ #: One- and two-letter English words a label can carry ("Bank of America")
62
+ #: that would otherwise tie every question that uses them. Applied only to
63
+ #: short tokens; longer stop words are in :data:`_STOP`.
64
+ _SHORT_STOP = _STOP | frozenset("am as at be go he if it me ok pm so up us vs we".split())
65
+
66
+ #: An English possessive or contraction ending: "Media's", "don't", "we'll".
67
+ _CLITIC = re.compile(r"['’](?:s|t|d|m|ll|re|ve)\b")
68
+
69
+
70
+ def _short_words(text: str) -> set[str]:
71
+ """The whole words of ``text`` under :data:`_SHORT` characters, as a short
72
+ token may match them: possessive and contraction endings removed first
73
+ (no "s" from "Media's", no "t" from "don't"), and words split on any
74
+ non-letter, accented letters included, so "Société" stays one word and
75
+ yields no "t" or "g". A set, so a label repeating one ("J&J") counts it once."""
76
+ words = re.findall(r"[^\W_]+", _CLITIC.sub("", text.lower()))
77
+ return {w for w in words if len(w) < _SHORT and w not in _SHORT_STOP}
78
+
79
+
57
80
  # C0 control chars (0x00-0x1F) + DEL (0x7F), including \n and \r — a
58
81
  # maliciously-labeled instance (e.g. rdfs:label containing "\nignore
59
82
  # previous instructions") must render on a single bullet line and
@@ -103,7 +126,10 @@ def _token_substring_retrieve(
103
126
  dump: bool = False,
104
127
  ) -> list[_T]:
105
128
  """Top-k items whose ``get_labels(item)`` tokens appear (as
106
- substrings) in the question, or vice versa.
129
+ substrings) in the question, or vice versa. A token of three or more
130
+ characters matches as a substring; a shorter one ("HP", "3M", "31")
131
+ only as a whole word of the question (:func:`_short_words`), since as a
132
+ substring "ge" would be found in "manager".
107
133
 
108
134
  Both-direction substring matching survives plural/inflection
109
135
  mismatches (e.g. "Transistors" in the question vs. a "Transistor"
@@ -128,15 +154,19 @@ def _token_substring_retrieve(
128
154
  old ``if hits:`` guard dropped it before ``k`` was ever applied).
129
155
  """
130
156
  ql = question.lower()
131
- q_tokens = {t for t in re.findall(r"[a-z0-9]+", ql) if len(t) >= 3 and t not in _STOP}
157
+ q_tokens = {t for t in re.findall(r"[a-z0-9]+", ql) if len(t) >= _SHORT and t not in _STOP}
158
+ q_short = _short_words(question)
132
159
  scored: list[tuple[int, int, _T]] = []
133
160
  for item in items:
134
161
  hits = 0
135
162
  best_len = 999
136
163
  for lab in get_labels(item):
137
164
  labl = lab.lower()
138
- lab_tokens = [t for t in re.findall(r"[a-z0-9]+", labl) if len(t) >= 3]
165
+ lab_tokens = [t for t in re.findall(r"[a-z0-9]+", labl) if len(t) >= _SHORT]
139
166
  h = sum(1 for t in lab_tokens if t in ql)
167
+ # A short token ("HP", "3M", the "31" of "Account Name 31") counts
168
+ # only when the question has it as a whole word (:func:`_short_words`).
169
+ h += len(_short_words(lab) & q_short)
140
170
  h += sum(1 for t in q_tokens if t in labl and not any(t == lt for lt in lab_tokens))
141
171
  if h > hits:
142
172
  hits, best_len = h, len(labl)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arango-query-core"
3
- version = "0.2.8"
3
+ version = "0.2.9"
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" }
@@ -38,6 +38,81 @@ def test_topk_and_ranking() -> None:
38
38
  assert top2[1] == e2
39
39
 
40
40
 
41
+ def _companies() -> list[GroundedEntity]:
42
+ """Short-named companies among longer names, as a CRM's account list holds them."""
43
+ names = ["HP", "GE", "3M", "EY", "AT&T", "Acme Holdings", "Meridian Logistics", "Bank of America"]
44
+ names += [f"{a} {n}" for a in ("Blue", "Atlas", "Golden") for n in ("Systems", "Energy", "Media")]
45
+ return [GroundedEntity(id=n, labels=(n,), type="Account") for n in names]
46
+
47
+
48
+ def test_short_name_is_found_as_a_whole_word() -> None:
49
+ # A name whose only distinctive word is under three characters ("HP",
50
+ # "GE", "3M") was never retrieved: the scorer dropped every such token.
51
+ index = LabelIndex(_companies())
52
+ for question, name in [
53
+ ("Who owns HP?", "HP"),
54
+ ("What is the ARR of GE?", "GE"),
55
+ ("When does 3M renew?", "3M"),
56
+ ("Show EY's open opportunities", "EY"),
57
+ ("Who is the account owner for AT&T?", "AT&T"),
58
+ ]:
59
+ assert [e.id for e in index.retrieve(question)][:1] == [name], question
60
+
61
+
62
+ def test_short_token_does_not_match_inside_a_longer_word() -> None:
63
+ # "ge" is inside "manager" and "changed"; "hp" and "ey" inside nothing here.
64
+ # A short token counts only as a whole word, so none of these are offered.
65
+ index = LabelIndex(_companies())
66
+ for question in ("Who is the account manager?", "What changed in usage?", "Which keys expire?"):
67
+ ids = {e.id for e in index.retrieve(question)}
68
+ assert not ids & {"HP", "GE", "3M", "EY", "AT&T"}, (question, ids)
69
+
70
+
71
+ def test_short_stopword_in_a_label_matches_nothing() -> None:
72
+ # "of" in "Bank of America" must not tie every question that says "of".
73
+ index = LabelIndex(_companies())
74
+ assert "Bank of America" not in {e.id for e in index.retrieve("list of all renewals")}
75
+
76
+
77
+ def test_possessive_and_contraction_leave_no_short_fragment() -> None:
78
+ # "Media's" and "don't" must not yield a token "s" or "t" that matches
79
+ # "S.p.A." or "AT&T"; a possessive still finds its exact name first.
80
+ names = ["Atlas Media", "Atlas Media S.p.A.", "AT&T", "Macy's", "Acme"]
81
+ index = LabelIndex([GroundedEntity(id=n, labels=(n,), type="") for n in names])
82
+ assert index.retrieve("What is Atlas Media's ARR?")[0].id == "Atlas Media"
83
+ assert index.retrieve("Which accounts don't renew?") == []
84
+ assert index.retrieve("Who's the owner?") == []
85
+ assert index.retrieve("Who owns Macy's?")[0].id == "Macy's"
86
+
87
+
88
+ def test_accented_name_yields_no_short_fragments() -> None:
89
+ # "Société Générale" splits, ASCII-wise, into "soci t g n rale"; its short
90
+ # pieces must not match a question's stray letters.
91
+ entity = GroundedEntity(id="SG", labels=("Société Générale",), type="")
92
+ index = LabelIndex([entity])
93
+ assert index.retrieve("plan t for g n accounts") == []
94
+ assert index.retrieve("What is Société Générale's ARR?") == [entity]
95
+
96
+
97
+ def test_repeated_short_token_counts_once() -> None:
98
+ # "J&J" holds "j" twice; counted twice it would outrank the label "J".
99
+ # Counted once, the two tie and the shorter label wins.
100
+ names = ["J&J", "J"]
101
+ index = LabelIndex([GroundedEntity(id=n, labels=(n,), type="") for n in names])
102
+ assert [e.id for e in index.retrieve("Who is J?")] == ["J", "J&J"]
103
+
104
+
105
+ def test_number_in_a_name_ranks_its_own_name_first() -> None:
106
+ # "Account Name 31" differs from its neighbours only by a two-digit number;
107
+ # "31" must pick it out, and must not match inside "131" or "2031".
108
+ names = [f"Account Name {n}" for n in range(1, 201)]
109
+ index = LabelIndex([GroundedEntity(id=n, labels=(n,), type="") for n in names])
110
+ assert index.retrieve("Who is the owner of Account Name 31?")[0].id == "Account Name 31"
111
+ assert index.retrieve("Who is the owner of Account Name 7?")[0].id == "Account Name 7"
112
+ top = [e.id for e in index.retrieve("renewals of Account Name due in 2031")]
113
+ assert "Account Name 31" not in top[:1]
114
+
115
+
41
116
  def test_empty_on_no_match() -> None:
42
117
  entity = GroundedEntity(id="http://ex.org/x", labels=("Completely Unrelated",), type="")
43
118
  index = LabelIndex([entity])
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(gh run *)",
5
- "Bash(git worktree *)"
6
- ]
7
- }
8
- }