vertiq 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 (79) hide show
  1. {vertiq-0.2.2/vertiq.egg-info → vertiq-0.2.3}/PKG-INFO +15 -1
  2. {vertiq-0.2.2 → vertiq-0.2.3}/README.md +14 -0
  3. {vertiq-0.2.2 → vertiq-0.2.3}/pyproject.toml +1 -1
  4. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_report.py +59 -0
  5. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/__init__.py +1 -1
  6. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/patterns.py +10 -0
  7. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/report.py +68 -4
  8. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/tables.py +56 -6
  9. {vertiq-0.2.2 → vertiq-0.2.3/vertiq.egg-info}/PKG-INFO +15 -1
  10. {vertiq-0.2.2 → vertiq-0.2.3}/LICENSE +0 -0
  11. {vertiq-0.2.2 → vertiq-0.2.3}/setup.cfg +0 -0
  12. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_autotune.py +0 -0
  13. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_boost.py +0 -0
  14. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_boundary.py +0 -0
  15. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_category.py +0 -0
  16. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_context.py +0 -0
  17. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_core.py +0 -0
  18. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_design.py +0 -0
  19. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_diagnose.py +0 -0
  20. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_drift.py +0 -0
  21. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_guard.py +0 -0
  22. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_guides.py +0 -0
  23. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_mapping.py +0 -0
  24. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_mapping_messy.py +0 -0
  25. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_menu.py +0 -0
  26. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_narrate.py +0 -0
  27. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_patterns.py +0 -0
  28. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_plan.py +0 -0
  29. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_public_benchmark.py +0 -0
  30. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_segments.py +0 -0
  31. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_sequences_profiles.py +0 -0
  32. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_statistics.py +0 -0
  33. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_summary.py +0 -0
  34. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_survival.py +0 -0
  35. {vertiq-0.2.2 → vertiq-0.2.3}/tests/test_tasks.py +0 -0
  36. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/actions.py +0 -0
  37. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/affinity.py +0 -0
  38. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/autotune.py +0 -0
  39. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/boost.py +0 -0
  40. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/category.py +0 -0
  41. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/causal.py +0 -0
  42. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/checkout.py +0 -0
  43. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/churn.py +0 -0
  44. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/context.py +0 -0
  45. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/contract.py +0 -0
  46. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/dataset.py +0 -0
  47. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/design.py +0 -0
  48. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/diagnose.py +0 -0
  49. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/drift.py +0 -0
  50. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/experiments.py +0 -0
  51. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/features.py +0 -0
  52. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/guard.py +0 -0
  53. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/harness.py +0 -0
  54. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/insights.py +0 -0
  55. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/inventory.py +0 -0
  56. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/learn.py +0 -0
  57. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/mapping.py +0 -0
  58. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/menu.py +0 -0
  59. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/narrate.py +0 -0
  60. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/offers.py +0 -0
  61. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/plan.py +0 -0
  62. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/plugins.py +0 -0
  63. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/profiles.py +0 -0
  64. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/research.py +0 -0
  65. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/rules.py +0 -0
  66. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/sequences.py +0 -0
  67. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/sequential.py +0 -0
  68. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/summary.py +0 -0
  69. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/survival.py +0 -0
  70. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/tasks.py +0 -0
  71. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/trends.py +0 -0
  72. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/uplift.py +0 -0
  73. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/value.py +0 -0
  74. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq/verticals.py +0 -0
  75. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq.egg-info/SOURCES.txt +0 -0
  76. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq.egg-info/dependency_links.txt +0 -0
  77. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq.egg-info/entry_points.txt +0 -0
  78. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq.egg-info/requires.txt +0 -0
  79. {vertiq-0.2.2 → vertiq-0.2.3}/vertiq.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vertiq
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Self-improving decision intelligence: a local language model that maps any operational dataset and decision models trained on real outcomes, improved through recursive self-improvement under an evaluation gate.
5
5
  Author: Biswajit Tripathy
6
6
  License: Apache-2.0
@@ -298,6 +298,20 @@ Recency baseline AUC 0.761.
298
298
  | Auto (chose boosted) | 0.812 | 0.795–0.827 | +0.043 to +0.059 |
299
299
  | scikit-learn logistic regression (reference) | 0.807 | | |
300
300
 
301
+ ### Real data across sectors
302
+
303
+ `vertiq-report` on public data from 5 sectors, nothing tuned per sector; windows of 30, 60 and 90 days, each through the
304
+ gate with its confidence corrected for the three, and its segment check (`benchmarks/sectors/fetch.sh`, then
305
+ `python benchmarks/sectors/run.py`; full reports in `benchmarks/sectors/reports/`).
306
+
307
+ | Sector | Data | Size | Question | Result | Patterns kept |
308
+ |---|---|---|---|---|---|
309
+ | Retail | UCI Online Retail II: a UK online shop's invoice lines, 2009-2010 | 28,816 records, 9,612 entities | Will a customer buy again, and how much? | Promoted: 30 days, AUC 0.881 vs 0.776; 60 days, AUC 0.888 vs 0.750; 90 days, AUC 0.890 vs 0.708 | 20 of 471 |
310
+ | Food safety | City of Chicago food inspections since 2019 (up to 150,000) | 137,034 records, 27,264 entities | Which establishments will fail an inspection? | Promoted: 30 days, AUC 0.813 vs 0.698; 60 days, AUC 0.822 vs 0.703; 90 days, AUC 0.809 vs 0.692 | 8 of 94 |
311
+ | Animal services | Austin Animal Center intakes (up to 200,000) | 173,812 records, 156,287 entities | Which animals will come back to the shelter? | Not promoted: best AUC 0.870 vs 0.865; the current rule stays | 20 of 266 |
312
+ | Manufacturing | Microsoft Azure predictive maintenance sample: 100 machines, 2015 (errors, failures, machine models) | 4,680 records, 100 entities | Which machines will fail? | Promoted: 60 days, AUC 0.720 vs 0.545. Kept the rule: 30 days, 90 days | 7 of 221 |
313
+ | Transport | NYC motor vehicle collisions since January 2025 (up to 200,000) | 121,970 records, 43,061 entities | Where will collisions happen again? | Promoted: 30 days, AUC 0.971 vs 0.894; 60 days, AUC 0.958 vs 0.859; 90 days, AUC 0.950 vs 0.840. Next value promoted: average error 0.44 vs 0.48 | 5 of 86 |
314
+
301
315
  ### Real exports (held out)
302
316
 
303
317
  Six public exports from different sectors, never used for training; a role is correct when it is one of the accepted answers
@@ -268,6 +268,20 @@ Recency baseline AUC 0.761.
268
268
  | Auto (chose boosted) | 0.812 | 0.795–0.827 | +0.043 to +0.059 |
269
269
  | scikit-learn logistic regression (reference) | 0.807 | | |
270
270
 
271
+ ### Real data across sectors
272
+
273
+ `vertiq-report` on public data from 5 sectors, nothing tuned per sector; windows of 30, 60 and 90 days, each through the
274
+ gate with its confidence corrected for the three, and its segment check (`benchmarks/sectors/fetch.sh`, then
275
+ `python benchmarks/sectors/run.py`; full reports in `benchmarks/sectors/reports/`).
276
+
277
+ | Sector | Data | Size | Question | Result | Patterns kept |
278
+ |---|---|---|---|---|---|
279
+ | Retail | UCI Online Retail II: a UK online shop's invoice lines, 2009-2010 | 28,816 records, 9,612 entities | Will a customer buy again, and how much? | Promoted: 30 days, AUC 0.881 vs 0.776; 60 days, AUC 0.888 vs 0.750; 90 days, AUC 0.890 vs 0.708 | 20 of 471 |
280
+ | Food safety | City of Chicago food inspections since 2019 (up to 150,000) | 137,034 records, 27,264 entities | Which establishments will fail an inspection? | Promoted: 30 days, AUC 0.813 vs 0.698; 60 days, AUC 0.822 vs 0.703; 90 days, AUC 0.809 vs 0.692 | 8 of 94 |
281
+ | Animal services | Austin Animal Center intakes (up to 200,000) | 173,812 records, 156,287 entities | Which animals will come back to the shelter? | Not promoted: best AUC 0.870 vs 0.865; the current rule stays | 20 of 266 |
282
+ | Manufacturing | Microsoft Azure predictive maintenance sample: 100 machines, 2015 (errors, failures, machine models) | 4,680 records, 100 entities | Which machines will fail? | Promoted: 60 days, AUC 0.720 vs 0.545. Kept the rule: 30 days, 90 days | 7 of 221 |
283
+ | Transport | NYC motor vehicle collisions since January 2025 (up to 200,000) | 121,970 records, 43,061 entities | Where will collisions happen again? | Promoted: 30 days, AUC 0.971 vs 0.894; 60 days, AUC 0.958 vs 0.859; 90 days, AUC 0.950 vs 0.840. Next value promoted: average error 0.44 vs 0.48 | 5 of 86 |
284
+
271
285
  ### Real exports (held out)
272
286
 
273
287
  Six public exports from different sectors, never used for training; a role is correct when it is one of the accepted answers
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vertiq"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Self-improving decision intelligence: a local language model that maps any operational dataset and decision models trained on real outcomes, improved through recursive self-improvement under an evaluation gate."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -250,3 +250,62 @@ def test_search_inputs_read_as_words():
250
250
  from vertiq.menu import words
251
251
  assert words("share:bill[@item=Jeans]@180d") == "share of bills including Jeans over 180 days"
252
252
  assert words("since:bill[payment_mode=Cash]") == "time since the last bill with payment mode Cash"
253
+
254
+
255
+ def _write_csv(path, rows):
256
+ with open(path, "w", newline="") as f:
257
+ w = csv.DictWriter(f, fieldnames=list(rows[0]))
258
+ w.writeheader()
259
+ w.writerows(rows)
260
+
261
+
262
+ def _machines(tmp_path):
263
+ """100 machines numbered 1 to 100 (small whole numbers), an error log and a failure log, and a machine list."""
264
+ rnd = random.Random(9)
265
+ errors, failures = [], []
266
+ for m in range(1, 101):
267
+ for _ in range(rnd.randint(10, 40)):
268
+ errors.append({"datetime": f"2015-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d} 0{rnd.randint(0, 9)}:00:00",
269
+ "machineID": str(m), "errorID": rnd.choice(["error1", "error2", "error3"])})
270
+ for _ in range(rnd.randint(2, 9)):
271
+ failures.append({"datetime": f"2015-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d} 06:00:00",
272
+ "machineID": str(m), "failure": rnd.choice(["comp1", "comp2", "comp3"])})
273
+ _write_csv(tmp_path / "errors.csv", errors)
274
+ _write_csv(tmp_path / "failures.csv", failures)
275
+ _write_csv(tmp_path / "machines.csv", [{"machineID": str(m), "model": f"model{m % 4 + 1}", "age": str(m % 20)} for m in range(1, 101)])
276
+
277
+
278
+ def test_several_event_files_merge_on_a_small_number_id(tmp_path):
279
+ _machines(tmp_path)
280
+ cfg = {"records": [{"file": "errors.csv", "event": "error"}, {"file": "failures.csv", "event": "failure"}],
281
+ "entity_table": {"file": "machines.csv"}, "target": "failure", "noun": "events", "entities": "machines"}
282
+ collect = {}
283
+ text = report.run(cfg, tmp_path, collect)
284
+ assert "entity 'machineID'" in text and "Merged:" in text and collect["entities"] == 100
285
+ assert any(p["target"] == "failure" for p in collect["predictions"])
286
+ # a failure code exists only on failures: no "failure is comp1 ... have type error" patterns
287
+ assert not any("have type" in f and "failure is" in f for f in collect["patterns"]["found"])
288
+
289
+
290
+ def test_identifiers_are_found_by_name_and_never_taken_as_amounts():
291
+ from vertiq.tables import id_named, id_worded, propose
292
+ assert id_named("machineID") and id_named("customer_id") and id_named("Customer ID") and not id_named("paid")
293
+ assert id_worded("license_") and id_worded("Invoice No") and not id_worded("number_of_persons_injured")
294
+ rnd = random.Random(2)
295
+ rows = [{"date": f"2024-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d}", "store_id": str(rnd.randint(1, 40)),
296
+ "license_": str(rnd.randint(1, 2_500_000)), "amount": f"{rnd.uniform(5, 500):.2f}"} for _ in range(800)]
297
+ m, _ = propose(rows)
298
+ assert m.entity == "store_id" and m.value == "amount"
299
+
300
+
301
+ def test_an_outcome_named_by_value_sets_the_event_type(tmp_path):
302
+ rnd = random.Random(4)
303
+ rows = [{"inspection_id": str(100000 + i), "establishment": f"Shop {rnd.randint(1, 150)}",
304
+ "inspection_date": f"202{rnd.randint(1, 4)}-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d}",
305
+ "results": rnd.choice(["Pass", "Pass", "Fail", "Pass w/ Conditions"])} for i in range(1500)]
306
+ _write_csv(tmp_path / "inspections.csv", rows)
307
+ collect = {}
308
+ text = report.run({"records": {"file": "inspections.csv"}, "target": "Fail", "noun": "inspections"}, tmp_path, collect)
309
+ assert "'Fail' is a value of 'results'" in text
310
+ assert all(p["target"] == "Fail" for p in collect["predictions"])
311
+ assert not any("results is" in f for f in collect["patterns"]["found"]) # the type column is not also an attribute
@@ -20,4 +20,4 @@ it likes.
20
20
  experiments comparing message variants against each other and a control group
21
21
  verticals per-vertical settings (windows, nouns, currency, tactics)
22
22
  """
23
- __version__ = "0.2.2"
23
+ __version__ = "0.2.3"
@@ -157,10 +157,20 @@ def find(ds: Dataset, *, item_groups: dict[str, str] | None = None, noun: str =
157
157
  tests.append({"kind": "value", "feature": k[1], "n_with": a[0], "n_without": b[0],
158
158
  "with": ma, "without": mb, "p": _mean_p_sums(a, b),
159
159
  "big": abs(ma - mb) >= min_value_gap * abs(overall)})
160
+ # an attribute recorded on one event type only (a failure code exists only on failures) says nothing new
161
+ # about the type: comparing the two would report how the data was exported, not a pattern
162
+ types_of: dict[str, set] = defaultdict(set)
163
+ for e in events:
164
+ for k, v in (e.attrs or {}).items():
165
+ if v not in (None, "", "-"):
166
+ types_of[f"attr:{k}"].add(e.type)
167
+ one_type = {src for src, ts in types_of.items() if len(ts) == 1 and len({e.type for e in events}) > 1}
160
168
  for i, ka in enumerate(keys):
161
169
  for kb in keys[i + 1:]:
162
170
  if ka[0] == kb[0]:
163
171
  continue # levels of one attribute exclude each other by construction
172
+ if {ka[0], kb[0]} & one_type and "type" in (ka[0], kb[0]):
173
+ continue
164
174
  if ka[0].startswith("item:") and kb[0].startswith("item:"):
165
175
  continue # items bought together: vertiq.affinity, with its own thresholds
166
176
  # context (weekday, part of day, group) is the condition; otherwise the first feature is
@@ -12,6 +12,13 @@ config.json names the tables and how they map to the contract; nothing else is c
12
12
  "entity_table": {"file": "customers.csv", "trainable": "trainable"},
13
13
  "noun": "orders", "currency": "₹", "entities": "customers"}
14
14
 
15
+ "records" can also list several event files about the same entities, each named by its "event" (default: the
16
+ file name), with "target" naming the event to predict. "target" may also be an outcome as written in a column ("Fail" in a
17
+ results column); that column then gives each record's type:
18
+
19
+ {"records": [{"file": "errors.csv", "event": "error"}, {"file": "failures.csv", "event": "failure"}],
20
+ "entity_table": {"file": "machines.csv"}, "target": "failure", "noun": "events", "entities": "machines"}
21
+
15
22
  Predictions try return windows of 30, 60 and 90 days ("windows": [...] or "window_days": N to change), with the
16
23
  gate's confidence corrected for the number tried, and search the inputs vertiq.menu proposes from the data
17
24
  ("tune": false to skip). A column listing each record's items in one cell is read as line items when no items
@@ -107,10 +114,44 @@ def read_table(path: str | Path) -> list[dict]:
107
114
  return [dict(zip(head, r + [""] * (len(head) - len(r)))) for r in rows[at + 1:]]
108
115
 
109
116
 
117
+ def _build_many(config: dict, base: Path) -> tuple[object, dict, list[str]]:
118
+ """Several event files about the same entities (a machine's error log and its failure records, a customer's
119
+ orders and returns): each file is read and mapped on its own, its events named after the file (or its
120
+ "event"), and the files merged on the entity; an entity table joins once, at the end."""
121
+ from .contract import Dataset, Entity
122
+ ents: dict[str, Entity] = {}
123
+ events, groups, log = [], {}, []
124
+ for r in config["records"]:
125
+ name = r.get("event") or Path(r["file"]).stem
126
+ sub = {**config, "records": r, "entity_table": None, "_entity_table": config.get("entity_table"), "event": name}
127
+ ds, g, lg = build(sub, base)
128
+ log += [f"{name}: {x}" for x in lg]
129
+ if ds is None:
130
+ continue
131
+ for e in ds.entities:
132
+ old = ents.get(e.id)
133
+ ents[e.id] = e if old is None else Entity(e.id, old.group or e.group, {**(e.attrs or {}), **(old.attrs or {})})
134
+ events += ds.events
135
+ groups.update(g)
136
+ if not events:
137
+ return None, {}, log
138
+ ds = Dataset([ents[k] for k in sorted(ents)], sorted(events, key=lambda e: e.at))
139
+ log.append(f"Merged: {len(events):,} events of {len({e.type for e in events})} types from {len(config['records'])} files")
140
+ if config.get("entity_table"):
141
+ from .tables import attach_entities
142
+ et = config["entity_table"]
143
+ ds, more, _labels = attach_entities(ds, read_table(base / et["file"]), et.get("key"))
144
+ log += [f"Entities: {n}" for n in more]
145
+ return ds, groups, log
146
+
147
+
110
148
  def build(config: dict, base: Path = Path(".")) -> tuple[object, dict, list[str]]:
111
149
  """The dataset, item groups and loading notes from a config. Without a mapping, Vertiq proposes one from
112
- the values (vertiq.tables.propose); without join columns, it finds them (vertiq.tables.find_join)."""
150
+ the values (vertiq.tables.propose); without join columns, it finds them (vertiq.tables.find_join).
151
+ "records" may also be a list of event files about the same entities (see _build_many)."""
113
152
  from dataclasses import replace
153
+ if isinstance(config["records"], list):
154
+ return _build_many(config, base)
114
155
  from .tables import find_join, find_lines, propose, records_from_lines
115
156
  rec = config["records"]
116
157
  rows = read_table(base / rec["file"])
@@ -138,12 +179,27 @@ def build(config: dict, base: Path = Path(".")) -> tuple[object, dict, list[str]
138
179
  if rec.get("mapping"):
139
180
  proposal = Mapping.parse(rec["mapping"])
140
181
  else:
141
- proposal, why = propose(rows, record_key=it.get("event_key"), latest=bool(shape and shape["counts"]))
182
+ hint = None
183
+ et = config.get("entity_table") or config.get("_entity_table")
184
+ if et: # the entity table names the entity: its key's column
185
+ from .tables import entity_key_hint
186
+ hint = entity_key_hint(rows, read_table(base / et["file"]))
187
+ proposal, why = propose(rows, record_key=it.get("event_key"), latest=bool(shape and shape["counts"]),
188
+ entity_hint=hint)
142
189
  log += [f"Mapping proposal: {w}" for w in why]
143
190
  if proposal is None:
144
191
  return None, {}, log
145
192
  m, notes = repair(proposal, rows, exclude={it["event_key"]} if it.get("event_key") else ())
146
193
  ds, problems = apply(m, rows)
194
+ target = config.get("target")
195
+ if target and ds.events and target not in {e.type for e in ds.events}:
196
+ # the outcome is named as written in the data ("Fail" in a results column): that column is the event type
197
+ holder = next((a for a in m.attrs if any(str((e.attrs or {}).get(a, "")).strip() == target for e in ds.events)), None)
198
+ if holder:
199
+ ds = type(ds)(ds.entities, [replace(e, type=str((e.attrs or {}).get(holder) or "").strip() or "other",
200
+ attrs={k: v for k, v in (e.attrs or {}).items() if k != holder})
201
+ for e in ds.events], ds.actions)
202
+ log.append(f"Target: {target!r} is a value of {holder!r}, so {holder!r} gives each record's type")
147
203
  if ds.events and not any(e.type for e in ds.events):
148
204
  noun = config.get("noun", "")
149
205
  name = config.get("event") or (noun[:-1] if noun.endswith("s") else noun) or "record"
@@ -173,11 +229,16 @@ def build(config: dict, base: Path = Path(".")) -> tuple[object, dict, list[str]
173
229
  return ds, groups, log
174
230
 
175
231
 
176
- def run(config: dict, base: Path = Path(".")) -> str:
232
+ def run(config: dict, base: Path = Path("."), collect: dict | None = None) -> str:
233
+ """The report as Markdown. `collect`, when given, is filled with the same results as data (loading notes,
234
+ counts, patterns, combinations, and each prediction task's gate report) for benchmarks to record."""
235
+ collect = collect if collect is not None else {}
177
236
  ds, groups, log = build(config, base)
237
+ collect["log"] = log
178
238
  noun, cur = config.get("noun", "records"), config.get("currency", "")
179
239
  if ds is None:
180
240
  return "\n".join(["# Vertiq report", "", "## Loading", ""] + [f"- {x}" for x in log] + ["", "No mapping could be proposed."]) + "\n"
241
+ collect.update(records=len(ds.events), entities=len(ds.entities))
181
242
  out = ["# Vertiq report", "", f"{len(ds.events)} {noun} from {len(ds.entities)} entities.", "", "## Loading", ""]
182
243
  out += [f"- {x}" for x in log]
183
244
  if not ds.events:
@@ -186,6 +247,7 @@ def run(config: dict, base: Path = Path(".")) -> str:
186
247
  days = config.get("summary_days") or max(1, span // 2)
187
248
  out += ["", "## What happened", "", narrate.summary(summary.summarise(ds, days=days), entities=config.get("entities", "entities"))]
188
249
  pat = patterns.find(ds, item_groups=groups or None, noun=noun, currency=cur)
250
+ collect["patterns"] = {k: pat.get(k) for k in ("status", "features", "tested")} | {"found": [f["text"] for f in pat.get("found", [])]}
189
251
  out += ["", "## Patterns", ""]
190
252
  if pat["status"] == "learning":
191
253
  out.append(f"Not enough {noun} to test patterns: {pat['records']}, {pat['need']} needed.")
@@ -197,6 +259,7 @@ def run(config: dict, base: Path = Path(".")) -> str:
197
259
  if lines:
198
260
  a = affinity.analyse(lines)
199
261
  combos = a.get("combos", [])
262
+ collect["combos"] = [{"items": c["items"], "bills_together": c["bills_together"], "lift": c["lift"]} for c in combos[:10]]
200
263
  out += ["", "## Items bought together", ""]
201
264
  out += ([f"- {' + '.join(c['items'])}: on {c['bills_together']} {noun} together, {c['lift']} times as often as chance" for c in combos[:10]]
202
265
  or [f"No pair of items appears together on at least {affinity.MIN_PAIR} {noun}, the minimum for a combination."])
@@ -218,7 +281,7 @@ def run(config: dict, base: Path = Path(".")) -> str:
218
281
  # several windows are several chances to pass the gate: correct the confidence for how many are tried
219
282
  conf = 1 - (1 - harness.CONFIDENCE) / len(windows)
220
283
  if len(windows) > 1:
221
- out.append(f"- Return windows tried: {', '.join(f'{w} days' for w in windows)}; each must pass the gate at "
284
+ out.append(f"- Windows tried: {', '.join(f'{w} days' for w in windows)}; each must pass the gate at "
222
285
  f"{conf:.1%} confidence, corrected for trying {len(windows)}.")
223
286
  tune = config.get("tune", TUNE)
224
287
  for w in windows:
@@ -227,6 +290,7 @@ def run(config: dict, base: Path = Path(".")) -> str:
227
290
  tasks.append(_task(ds, "value", target, max(windows), harness.CONFIDENCE, tune, out))
228
291
  else:
229
292
  out.append("- No single event type to predict; set \"target\" in the config.")
293
+ collect["predictions"] = [t.report() for t in tasks]
230
294
  if config.get("scores"):
231
295
  out += scores(tasks, base / config["scores"], cur, config.get("entities", "entities"))
232
296
  return "\n".join(out) + "\n"
@@ -24,13 +24,57 @@ def _vals(rows, c):
24
24
  return [str(r.get(c) if r.get(c) is not None else "").strip() for r in rows]
25
25
 
26
26
 
27
- def propose(rows: list[dict], record_key: str | None = None, latest: bool = False) -> tuple[Mapping | None, list[str]]:
27
+ _ID_NAME = (re.compile(r"(?i)(?:^|[\s_.\-])id$"), re.compile(r"[a-z]I[dD]$")) # customer_id, Customer ID, machineID
28
+
29
+
30
+ _ID_WORDS = re.compile(r"(?i)(?:^|[^a-z])(?:id|ids|no|nbr|num|number|code|key|ref|reference|licen[cs]e|account|acct|"
31
+ r"zip|postcode|pin|phone|mobile)(?:[^a-z]|$)|[a-z](?:Id|ID|No|Number|Code|Key)$")
32
+
33
+
34
+ def id_worded(column: str) -> bool:
35
+ """Whether a column's name says it holds codes rather than amounts: "license_", "Invoice No", "zip_code",
36
+ "accountNumber". Such a column is never proposed as the value."""
37
+ if re.search(r"(?i)(?:number|num|no)[\s_.]*of(?:[^a-z]|$)", column):
38
+ return False # "number of persons injured" is a count, not a code
39
+ return bool(_ID_WORDS.search(column.strip())) or id_named(column)
40
+
41
+
42
+ def id_named(column: str) -> bool:
43
+ """Whether a column's name says it holds identifiers: "id", "customer_id", "Customer ID", "machineID"."""
44
+ return any(p.search(column.strip()) for p in _ID_NAME)
45
+
46
+
47
+ def entity_key_hint(rows: list[dict], entity_rows: list[dict]) -> str | None:
48
+ """The column of `rows` whose values are the key of an entity table (a customer list, an asset register): the
49
+ table's first column with a distinct value on every row, matched by at least 90% of the column's values."""
50
+ ents = _records(entity_rows)
51
+ if not ents:
52
+ return None
53
+ key = next((c for c in ents[0] if len({str(r.get(c) or "").strip() for r in ents}) == len(ents)), None)
54
+ if key is None:
55
+ return None
56
+ keys = {str(r.get(key) or "").strip() for r in ents}
57
+ data = _records(rows)
58
+ best = None
59
+ for c in (data[0] if data else {}):
60
+ vals = {v for v in _vals(data, c) if v}
61
+ share = len(vals & keys) / max(1, len(vals))
62
+ if vals and share >= 0.9 and (best is None or share > best[0]):
63
+ best = (share, c)
64
+ return best[1] if best else None
65
+
66
+
67
+ def propose(rows: list[dict], record_key: str | None = None, latest: bool = False,
68
+ entity_hint: str | None = None) -> tuple[Mapping | None, list[str]]:
28
69
  """A mapping chosen from the values: the time is the date column that is filled best, with a separate
29
70
  time-of-day column if there is one; the value is the most complete amount column; the entity is the best
30
71
  identifier that repeats (a person or asset), else the best identifier; attributes are the remaining
31
72
  columns with 2 to 12 repeated labels, and columns that identify each record (kept for joins).
32
73
  `record_key`: a column known to identify the record itself (the key line items join on); it is kept as
33
- an attribute and never proposed as the entity, since it names the order, not whom it is about."""
74
+ an attribute and never proposed as the entity, since it names the order, not whom it is about.
75
+ `entity_hint`: a column known to hold the entity (its values are an entity table's key); it is the entity.
76
+ A column named as an identifier (id_named) that holds whole numbers counts as one with 20 or more values,
77
+ so machine 7 or store 12 are entities, not counts."""
34
78
  skip = structural_rows(rows)
35
79
  data = [r for i, r in enumerate(rows, start=1) if i not in skip]
36
80
  if not data:
@@ -58,7 +102,9 @@ def propose(rows: list[dict], record_key: str | None = None, latest: bool = Fals
58
102
  fmts = date_formats(_vals(data, time))
59
103
  clock = next((c for c in kind if c != time and sum(1 for v in _vals(data, c) if _TIME_OF_DAY.match(v)) >= 0.8 * len(data)), None)
60
104
  qcols = _quantities(data, kind)
61
- amounts = [c for c in kind if kind[c] == "number" and fitness(data, c, "value", kind) >= 0.5 and c not in qcols
105
+ # an identifier (named or joined as one) is never the amount
106
+ amounts = [c for c in kind if kind[c] == "number" and c != entity_hint and not id_worded(c)
107
+ and fitness(data, c, "value", kind) >= 0.5 and c not in qcols
62
108
  and not _id_numbers(_vals(data, c))]
63
109
  value = None
64
110
  if amounts:
@@ -75,12 +121,16 @@ def propose(rows: list[dict], record_key: str | None = None, latest: bool = Fals
75
121
  for c in kind:
76
122
  if c in (time, clock, value, record_key, item_col):
77
123
  continue
78
- f = fitness(data, c, "entity", kind)
79
- if f < 0.2:
80
- continue # records without an id become one-off entities (reported)
81
124
  ph = placeholder_ids(data, c) # once per column, not once per value
82
125
  real = [v for v in _vals(data, c) if v not in ph]
83
126
  distinct = len(set(real)) / max(1, len(real))
127
+ if c == entity_hint or (id_named(c) and real and all(v.isdigit() for v in real) and len(set(real)) >= 20):
128
+ # named or joined as an identifier: small whole numbers here are ids, not counts
129
+ ids.append((True if c == entity_hint else distinct < 0.98, 2.0 if c == entity_hint else len(real) / n, c))
130
+ continue
131
+ f = fitness(data, c, "entity", kind)
132
+ if f < 0.2:
133
+ continue # records without an id become one-off entities (reported)
84
134
  # an identifier is a code with letters and digits, a fixed-length whole number (an account, customer or phone
85
135
  # number), or a long number; short numbers and numbers with decimals are amounts or counts
86
136
  coded = _id_numbers(real) or sum(1 for v in real if any(ch.isdigit() for ch in v)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vertiq
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Self-improving decision intelligence: a local language model that maps any operational dataset and decision models trained on real outcomes, improved through recursive self-improvement under an evaluation gate.
5
5
  Author: Biswajit Tripathy
6
6
  License: Apache-2.0
@@ -298,6 +298,20 @@ Recency baseline AUC 0.761.
298
298
  | Auto (chose boosted) | 0.812 | 0.795–0.827 | +0.043 to +0.059 |
299
299
  | scikit-learn logistic regression (reference) | 0.807 | | |
300
300
 
301
+ ### Real data across sectors
302
+
303
+ `vertiq-report` on public data from 5 sectors, nothing tuned per sector; windows of 30, 60 and 90 days, each through the
304
+ gate with its confidence corrected for the three, and its segment check (`benchmarks/sectors/fetch.sh`, then
305
+ `python benchmarks/sectors/run.py`; full reports in `benchmarks/sectors/reports/`).
306
+
307
+ | Sector | Data | Size | Question | Result | Patterns kept |
308
+ |---|---|---|---|---|---|
309
+ | Retail | UCI Online Retail II: a UK online shop's invoice lines, 2009-2010 | 28,816 records, 9,612 entities | Will a customer buy again, and how much? | Promoted: 30 days, AUC 0.881 vs 0.776; 60 days, AUC 0.888 vs 0.750; 90 days, AUC 0.890 vs 0.708 | 20 of 471 |
310
+ | Food safety | City of Chicago food inspections since 2019 (up to 150,000) | 137,034 records, 27,264 entities | Which establishments will fail an inspection? | Promoted: 30 days, AUC 0.813 vs 0.698; 60 days, AUC 0.822 vs 0.703; 90 days, AUC 0.809 vs 0.692 | 8 of 94 |
311
+ | Animal services | Austin Animal Center intakes (up to 200,000) | 173,812 records, 156,287 entities | Which animals will come back to the shelter? | Not promoted: best AUC 0.870 vs 0.865; the current rule stays | 20 of 266 |
312
+ | Manufacturing | Microsoft Azure predictive maintenance sample: 100 machines, 2015 (errors, failures, machine models) | 4,680 records, 100 entities | Which machines will fail? | Promoted: 60 days, AUC 0.720 vs 0.545. Kept the rule: 30 days, 90 days | 7 of 221 |
313
+ | Transport | NYC motor vehicle collisions since January 2025 (up to 200,000) | 121,970 records, 43,061 entities | Where will collisions happen again? | Promoted: 30 days, AUC 0.971 vs 0.894; 60 days, AUC 0.958 vs 0.859; 90 days, AUC 0.950 vs 0.840. Next value promoted: average error 0.44 vs 0.48 | 5 of 86 |
314
+
301
315
  ### Real exports (held out)
302
316
 
303
317
  Six public exports from different sectors, never used for training; a role is correct when it is one of the accepted answers
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes