From 6d4a3a8f3104e6990e3c6fe9efb45018cb1853a6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 07:30:00 -0500 Subject: [PATCH] generate: check a copy as a copy even when its values read as the row's own key --- packages/flow/docs/GENERATE_CONTRACTS.md | 10 ++++--- .../openadapt_flow/generate/demo_analysis.py | 30 ++++++++----------- .../tests/test_generate_coincidences_r1.py | 25 ++++++++-------- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/packages/flow/docs/GENERATE_CONTRACTS.md b/packages/flow/docs/GENERATE_CONTRACTS.md index 0f79058570..01e1971409 100644 --- a/packages/flow/docs/GENERATE_CONTRACTS.md +++ b/packages/flow/docs/GENERATE_CONTRACTS.md @@ -242,10 +242,12 @@ user, to the value every demonstration held, or to a copy of a column of the row a key points to (an invoice line's price from its product). A key, a link to a row the write added, the year, month, week or day of a date the task gives, and a generated token (a UUID) are recognized as such. A link -and a column that holds the row's own key need two demonstrations; the own -key must sit in the same text in every row (`INV-{key}`), never as a mere -substring. Such a column is listed in the question, never checked or -left open on its own. A date part must differ between the demonstrations: a constant +needs two demonstrations. A column that is not a copy and, in two or more +demonstrations, holds the row's own key in the same text (`INV-{key}`) is +listed in the question as holding the row's own key, and is left unchecked +only if the answer names it, like any column that varies. A copy is +checked as a copy even when its values read as the own key (prices 15 and +25 on rows 1 and 2). A date part must differ between the demonstrations: a constant that happens to equal the month of every demonstration's date is a constant. A copy is taken only through a declared key or a column named after its table, and only when the source differs between demonstrations: diff --git a/packages/flow/openadapt_flow/generate/demo_analysis.py b/packages/flow/openadapt_flow/generate/demo_analysis.py index 3c419311d9..f09d7e26bb 100644 --- a/packages/flow/openadapt_flow/generate/demo_analysis.py +++ b/packages/flow/openadapt_flow/generate/demo_analysis.py @@ -686,13 +686,6 @@ def column_roles( if is_link: out[name] = ColumnRole("link") continue - template = None if one else _key_template(t, values, rows) - if template is not None: - out[name] = ColumnRole( - "varies", - detail={"why": "holds the row's own key", "template": template}, - ) - continue part = None if one else _date_part(values, [d for d, _ in rows]) if part is not None: out[name] = ColumnRole( @@ -716,6 +709,11 @@ def column_roles( if copied is not None: out[name] = ColumnRole("copied", detail=copied) continue + # Only once no copy fits: a copied price of 15 and 25 on rows 1 and + # 2 reads as ``{key}5``, and a copy is checked where this isn't. + if not one and _holds_own_key(t, values, rows): + out[name] = ColumnRole("varies", detail={"why": "holds the row's own key"}) + continue distinct = {v for v in values} if col.kind == "text" and distinct <= {None, ""}: # Free text the task doesn't give and the demonstrations left @@ -757,27 +755,25 @@ def column_roles( return out -def _key_template( +def _holds_own_key( t: Table, values: Sequence[Optional[str]], rows: Sequence[tuple["Demo", Mapping[str, Optional[str]]]], -) -> Optional[str]: - """``prefix{key}suffix`` when every row holds its own single-column key - between the same prefix and suffix (``INV-{key}``), else None.""" +) -> bool: + """Does every row hold its own single-column key between the same prefix + and suffix (``INV-{key}``)? Only the question's wording uses this: such + a column is never checked unless the answer leaves it out.""" if len(t.primary_key) != 1 or not values: - return None + return False shapes = set() for v, (_, r) in zip(values, rows): key = r.get(t.primary_key[0]) if v is None or key is None or v == key or str(key) not in v: - return None + return False i = v.index(str(key)) shapes.add((v[:i], v[i + len(str(key)) :])) - if len(shapes) != 1: - return None - prefix, suffix = next(iter(shapes)) - return prefix + "{key}" + suffix + return len(shapes) == 1 def _date_part( diff --git a/packages/flow/tests/test_generate_coincidences_r1.py b/packages/flow/tests/test_generate_coincidences_r1.py index f5f3bfc028..c309fa60f9 100644 --- a/packages/flow/tests/test_generate_coincidences_r1.py +++ b/packages/flow/tests/test_generate_coincidences_r1.py @@ -211,12 +211,12 @@ def demo(kind: str, values: dict[str, Any], write: Callable[[], Any]) -> None: first = json.loads((work / "out" / "report.json").read_text()) questions = (work / "out" / "QUESTIONS.md").read_text() answers = {q["key"]: q["proposal"] for q in first["questions"]} - # The person names the two columns that hold a row's own key, or what - # reads like one (a sale's price, an audit row's reference). + # The person names the column that holds a row's own key (an audit row's + # reference). A sale's price only reads like one: it is a copy. bk = answers["bookkeeping"] bk = json.loads(json.dumps(bk)) if isinstance(bk, dict) else {"accept": True} named = bk.setdefault("unchecked", {}) - for table, col in (("sales", "price"), ("job_audit", "ref")): + for table, col in (("job_audit", "ref"),): if col not in named.setdefault(table, []): named[table].append(col) answers["bookkeeping"] = bk @@ -320,19 +320,20 @@ def test_a_constant_equal_to_its_source_in_every_demo_is_asked( # -- review round 3 ------------------------------------------------------------ -def test_a_value_that_reads_as_the_own_key_is_asked_never_templated( - shop: dict[str, Any], +@pytest.mark.parametrize("price,confirmed", [(None, True), (45, False)]) +def test_a_copy_that_reads_as_the_own_key_is_checked_as_a_copy( + shop: dict[str, Any], price: Any, confirmed: bool ) -> None: """Sales 1 and 2 copied prices 15 and 25, which read as ``{key}5``. The - column is listed in the question; nothing checks that template, so the - app's real price for sale 3 (40) passes once the person names it.""" + column is a copy of the product's price, never asked about, so a wrong + 45 on the next sale is refuted and the real price (40) passes.""" - assert "sales: " in shop["questions"] and "price" in shop["questions"] refused_first = shop["first"]["kinds"]["create_sale"]["refused"] - assert any("sales.price" in r for r in refused_first), refused_first - v = {"product": "Doohickey", "ref": "S-3"} - out = _run(shop, "create_sale", v, lambda: shop["app"].sale(v)) - assert out.startswith("CONFIRMED"), out + assert not any("sales.price" in r for r in refused_first), refused_first + assert "sales.price" not in json.dumps(shop["first"]["questions"]) + v = {"product": "Doohickey", "ref": f"S-{price}"} + out = _run(shop, "create_sale", v, lambda: shop["app"].sale(v, price=price)) + assert out.startswith("CONFIRMED") is confirmed, out def test_an_audit_row_holding_its_own_key_can_be_named(shop: dict[str, Any]) -> None: