Skip to content

feat: experience_keys and type_casting on the feature entry points - #62

Merged
abbaseya merged 9 commits into
mainfrom
feat/per-call-bucketing-attributes
Sep 17, 2026
Merged

abbaseya merged 9 commits into
mainfrom
feat/per-call-bucketing-attributes

Conversation

@abbaseya

@abbaseya abbaseya commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the two feature-scoped per-call controls the shipped documentation already promises Python callers and the package did not have:

context.run_feature("checkout-banner", experience_keys=["checkout-test"], type_casting=False)
context.run_features(experience_keys=["checkout-test"])
context.diagnose_feature("checkout-banner", experience_keys=["checkout-test"])
  • experience_keys — narrows which declaring experiences a feature call evaluates. It decides precedence, not only membership: run_feature returns the first experience that resolves in config order, so when two experiences carry the same feature this is the caller's only way to choose which one decides it.
  • type_casting — returns feature variables as the served config stores them. The boolean cast cannot fail, so it cannot signal: a stored "on", "enabled" or 2 reaches the caller as False with no error and no way to see what config holds. This is the window.

Both are keyword-only with behaviour-preserving defaults, so no existing call changes behaviour.

Python's gap here was neither the JS forwarding defect nor the Ruby translation gap — its per-call surface is a keyword-parameter list, so a control either exists and is threaded or does not exist and a caller gets TypeError. Nothing was ever dropped or accepted-and-ignored; the controls simply were not there.

Capabilities

CAP-1 experience_keys at the evaluation seam (resolve_feature / resolve_features), exposed on both feature entry points. select_experience gains no parameter — it is the pure stateless bucketing seam, and the three absence assertions in test_anchored_experience_selection.py remain valid and unedited.
CAP-2 type_casting gating the single point variables are read off the carrying change.
CAP-3 diagnose_feature takes experience_keys and deliberately not type_casting — the diagnostic returns a reason and no variables, so the flag could not change its verdict.
CAP-4 A declarative 11 x 6 control-by-surface table with six per-surface signature-equality assertions derived from it, plus behavioural assertions that an honoured control's value reaches the seam.
CAP-5 Shared authoring source (separate PR, below) + Google-style Args: blocks on all three feature entry points. Partial — see below.

Behaviour worth knowing

  • [] means "no filter", not "no experiences". Reading it the other way fails in the dangerous direction: a caller building the list from an empty upstream set would silently get every feature missing.
  • Config order decides precedence; the caller's key order does not. The filter is a set-membership test over the config list, never a map over caller keys.
  • A bare str is treated as absent. Without that guard "checkout" filters on its individual characters and matches nothing.
  • Narrowing omits; it does not pad DISABLED. This inverts against JS, PHP and Ruby, all of which pad. It is Python's documented miss convention, and FeatureStatus.DISABLED has never been constructed by this SDK.
  • type_casting=False is lossy, not more accurate — a json variable comes back as its stored string.

Verification

1186 passing (baseline 1134). ruff clean, mypy --strict clean across 47 files, project coverage 96.92% against an 85% floor, evaluation/ 97% against a 95% floor.

The four cross-SDK parity vectors in tests/parity/ pass byte-for-byte unchanged — they encode casting-on and no filter, which is the spec's own stated proof that both defaults are behaviour-preserving. No fixture was edited.

Three assertions were verified by mutation, because a test that cannot be shown to fail proves nothing:

  • CAP-4's matrix fails in both directions — removing type_casting from run_features ("Extra items in the right set") and adding a bogus parameter to run_experience ("Extra items in the left set").
  • The key-coercion fix's test fails against the pre-fix line, and only on its cast_off assertion.
  • CAP-1's "neither evaluates" spy fails when the membership check is moved after select_experience.

Test intent [GATED: B-G12]: four new test files, insertions only, zero deletions. No pre-existing test, fixture or snapshot was touched, so no ## Test intent changes row is owed.

What review and audit found

A two-round decision audit and two code-review rounds ran before this PR. They found four defects the green suite did not:

  1. A type-contract breaktype_casting=False leaked a non-str key into FeatureResult.variables, a field declared Mapping[str, Any]. Fixed; both branches now normalise.
  2. experience_keys silently stopped filtering when given a generatorresolve_features forwarded the iterable verbatim into each per-feature call, so feature feat(python-sdk): add deterministic bucketing and rule evaluation core #1 consumed the iterator and everything after it saw no filter. The caller got back features they had explicitly excluded. Fixed by materialising once.
  3. CAP-1's "neither evaluates" clause was asserted by nothing — a mutation moving the filter after bucketing left all 50 new tests green. Now defended by one spy assertion.
  4. A docstring regression, restored.

Decision records

Eight decisions from this workflow were promoted to records. Each is cited by its full stem — number and slug — because renumber.py reassigns the number and only the slug is immutable:

  • AgDR-0173-cap-4-ships-signature-equality-and-narrows-the-honoured-half
  • AgDR-0174-experience-keys-is-sequence-str-with-a-runtime-str-guard
  • AgDR-0175-type-casting-off-does-not-normalise-variable-keysdeprecated: it records the decision this PR reverses. Kept rather than deleted, because the reasoning is the point. Superseded by AgDR-0179.
  • AgDR-0176-precedence-proof-is-two-tests-not-one-parametrize
  • AgDR-0177-python-claims-the-shared-run-all-experience-keys-blockquote
  • AgDR-0178-shared-docs-edits-are-serialised-behind-the-sdk-work
  • AgDR-0179-both-cast-branches-normalise-variable-keys-to-str
  • AgDR-0180-cap-5-wiki-pages-are-not-this-workflows-deliverable

One further stem, AgDR-0011-the-ac1-full-pipeline-actually-reads-the-gate-wiring-test, is not from this workflow — it dates from 2026-07-19 and carries the same 2026-04-06-convert-python-sdk feature slug, which is how the gate resolves records. Cited for completeness, not claimed as this PR's work.

CAP-5 is deliberately partial

The shared authoring source and the docstrings are done. The three Python-only wiki pages named in the spec are not, and that is intentional — a wiki page is not a deliverable of the feature that changed the code it describes; the daily drift routine owns that refresh. Two consequences a reader should know:

  • python-sdk.wiki/CodeExamples.md still says run_feature and run_features are "identical to the experience methods". That was already false, and this change makes it false in the opposite direction.
  • Diagnostics.md still lists diagnose_feature(key) with no parameters.

CAP-3 is not undiscoverable despite that: the docstrings carry both controls and the reason type_casting is absent from the diagnostic.

Related

Test plan

  • CI green — ruff, mypy --strict, tests on the full Python matrix, both coverage floors
  • tests/parity/ executes and passes (214 tests; a zero-collected parity run is the failure mode worth watching for)
  • Confirm no existing caller changes behaviour — both parameters are additive and keyword-only

🤖 Generated with Claude Code

Effort

Scope: the ledger is keyed by repo + branch, and the docs/per-call-bucketing-attributes-specs
branch has hosted three sibling runs (JS wf-pcba01, PHP wf-pcba02, Python wf-pcba03) plus
the /audit-specs passes that produced their specs. So this table is the branch total, not
this PR's alone. This PR's run is the feature-conductor row "Implement Python per-call
bucketing attrs" (1h 39m 08s) and the sdk-python / sdk-test-writer rows beneath it.

Agent effort — per-call-bucketing-attributes-specs

Agent Task Time Output Cached
feature-conductor Implement per-call bucketing attributes 2h 34m 02s 278,279 120,617,718
feature-conductor Implement Python per-call bucketing attrs 1h 39m 08s 213,974 82,931,824
feature-conductor Conduct PHP SDK per-call attrs feature 1h 26m 00s 189,747 72,160,552
feature-conductor Conduct durable-visitor-id delivery 52m 39s 85,038 25,549,653
tracking-dev Durable vid fix, gate now waived 38m 31s 16,953 5,746,859
spec-auditor Audit bundle B-1-A 24m 26s 149,468 1,440,261
spec-auditor Audit bundle B-1-A 19m 26s 125,272 2,473,305
feature-conductor Implement coverage-summary-artifact 18m 41s 35,721 6,713,923
spec-auditor Audit Python SDK bundle B-1-A 17m 59s 118,291 2,483,096
spec-auditor Audit bundle B-1-A 17m 56s 111,216 2,219,414
sdk-php SDK-1 RED tests 16m 54s 48,810 5,258,391
spec-auditor Audit PHP SDK bundle B-1-A 15m 54s 106,125 2,241,108
decision-analyst Decision audit — analyst 15m 40s 73,063 11,152,538
spec-auditor Audit Ruby SDK bundle B-1-A 15m 02s 98,182 1,278,816
decision-auditor Round 2 audit verification 14m 46s 55,863 5,837,004
spec-auditor Audit the resolved spec 14m 38s 77,555 3,462,674
convert-code-reviewer Code review round 1 14m 29s 53,747 7,232,529
sdk-test-writer SDK-1 RED tests 14m 13s 70,323 12,341,924
decision-auditor Decision audit refuter pass 14m 13s 60,723 4,902,576
spec-auditor Audit bundle B-1-A 14m 01s 77,546 3,417,327
convert-code-reviewer Code review 13m 58s 57,079 10,721,870
spec-auditor Audit Android SDK bundle B-1-A 13m 41s 88,099 1,813,982
decision-analyst Decision audit analyst pass 12m 55s 55,819 7,175,157
cross-scope-auditor Cross-scope consistency pass 12m 51s 63,583 1,822,187
decision-analyst Decision audit analyst 12m 50s 54,280 6,998,081
decision-auditor Round 2 audit verdict 12m 35s 58,186 5,942,398
cross-scope-auditor Cross-scope consistency pass 10m 57s 52,246 1,754,695
cross-scope-auditor Cross-scope pass on resolved spec 10m 45s 51,146 1,370,795
cross-scope-auditor Ruby SDK cross-scope pass 10m 38s 55,685 2,036,166
cross-scope-auditor Cross-scope consistency pass 10m 23s 53,200 1,114,528
sdk-python SDK-4 docstring Args blocks 10m 15s 34,318 6,474,201
decision-auditor Decision audit — independent auditor 9m 49s 47,526 4,236,372
cross-scope-auditor Android SDK cross-scope pass 9m 12s 44,171 1,647,328
sdk-test-writer Fix CAP-3 over-claiming test titles 8m 55s 33,837 7,552,925
decision-auditor Decision audit refuter 8m 54s 39,680 5,260,326
cross-scope-auditor Python SDK cross-scope pass 8m 41s 47,206 1,014,999
sdk-test-writer Review round 1 test fixes 8m 36s 38,393 11,726,243
convert-code-reviewer Code review round 2 8m 30s 30,342 4,066,799
sdk-test-writer Write CAP-1/CAP-3 PHP SDK tests 8m 20s 45,024 8,223,458
readiness-assessor Readiness assessment 8m 06s 40,221 4,609,183
readiness-assessor Readiness assessment 8m 04s 37,196 3,876,891
cross-scope-auditor PHP SDK cross-scope pass 7m 55s 42,619 988,536
cross-scope-auditor Cross-scope consistency pass 7m 55s 39,174 1,256,173
convert-code-reviewer Code review round 2 7m 49s 28,729 2,628,309
sdk-test-writer SDK-2 RED tests 7m 27s 40,376 2,982,488
sdk-test-writer Round 2 test strengthening 7m 19s 36,617 6,282,217
readiness-assessor Readiness assessment 7m 18s 29,086 2,525,689
sdk-python SDK-3 CAP-4 matrix test 7m 00s 19,231 5,451,661
readiness-assessor Readiness assessment 6m 47s 34,022 4,490,569
sdk-test-writer SDK-2 CAP-2 RED tests 6m 19s 29,669 3,844,230
convert:premise-challenger Challenge the input premise 6m 17s 28,358 2,113,251
sdk-python Review round 1 fixes 6m 12s 15,616 4,016,717
sdk-test-writer Move CAP-3 tests into new files 6m 06s 26,276 3,324,254
sdk-test-writer SDK-1 browser CAP-3 gap 6m 00s 20,691 4,698,422
sdk-python Round 2 audit corrections 5m 18s 9,324 2,344,573
docs-sync Documentation sync 5m 06s 18,199 2,851,671
sdk-js Remove the two added comment lines 4m 47s 10,779 2,937,342
general-purpose Residual fixer F-006 4m 44s 32,957 1,282,054
docs-sync Docs sync 4m 34s 15,378 2,003,567
sdk-test-writer Add docstring-signature drift test 4m 14s 16,735 3,267,233
feature-conductor Implement PHP per-call bucketing attrs 4m 12s 15,796 4,547,003
sdk-python SDK-1 phase 1 RED tests 4m 07s 9,490 2,554,736
general-purpose Residual fixer F-014 3m 48s 27,077 602,364
sdk-python SDK-2 phase 1 RED tests 3m 46s 7,957 1,911,147
sdk-php DOC-1 PHPDoc 3m 38s 20,741 1,581,562
sdk-test-writer Write failing tests for CAP-1/CAP-3 experience_keys 3m 37s 20,474 1,625,310
sdk-python SDK-1 phase 2 GREEN implementation 3m 33s 14,454 4,079,527
sdk-test-writer Write RED tests for type_casting (CAP-2) 3m 24s 17,512 2,827,063
sdk-test-writer SDK-0 retry after gate unblock 3m 24s 13,675 1,493,802
general-purpose Patch cluster C-008 3m 23s 18,386 1,299,797
sdk-test-writer Write CAP-4 per-call control matrix tests 3m 21s 16,093 3,189,063
sdk-js SDK-1 GREEN implementation 3m 19s 7,586 2,404,479
sdk-test-writer CAP-2 baseline assertion gap 3m 18s 11,753 2,444,488
sdk-test-writer SDK-0 preview helper extraction 3m 12s 15,924 1,696,112
sdk-js Trim context.ts comment budget 3m 06s 12,320 2,052,677
sdk-js SDK-2 CAP-2 implementation 2m 54s 6,126 2,534,645
sdk-test-writer Write durable visitor id tests 2m 35s 9,665 1,496,459
sdk-python SDK-2 phase 2 GREEN implementation 2m 33s 8,050 2,326,174
general-purpose Patch cluster C-003 2m 30s 13,579 847,741
general-purpose Residual fix F-008 2m 26s 13,567 1,619,268
general-purpose Patch cluster C-013 2m 21s 13,587 859,611
tracking-dev DOC-1 shared SDK docs rows 2m 17s 12,592 829,483
general-purpose Residual fix F-005 2m 13s 15,367 658,310
general-purpose Patch cluster C-004 2m 12s 12,579 660,874
general-purpose Patch cluster C-011 2m 06s 10,689 1,343,168
general-purpose Repair apps-script-changes residues 2m 06s 15,911 630,311
sdk-test-writer Write RED test for _cast_variables key-type parity 2m 04s 9,022 1,705,664
sdk-test-writer Add two growth-only test blocks for code review round 1 2m 00s 7,820 1,699,962
general-purpose Patch cluster C-009 2m 00s 10,622 600,916
sdk-python Review round 2 docstring fix 1m 51s 7,386 1,631,603
general-purpose Patch cluster C-005 1m 49s 4,296 1,234,113
general-purpose Patch cluster C-001 1m 48s 8,970 1,338,450
sdk-js Retry context.ts comment trim 1m 47s 5,205 1,425,987
general-purpose Patch cluster C-007 1m 47s 8,732 1,132,719
general-purpose Patch cluster C-010 1m 46s 8,620 508,953
general-purpose Patch cluster C-002 1m 45s 8,466 1,222,749
general-purpose Residual fix F-013 1m 43s 9,458 800,625
general-purpose Patch cluster C-010 1m 39s 9,569 1,073,636
general-purpose Patch cluster C-001 1m 38s 9,278 859,328
general-purpose Patch cluster C-012 1m 37s 9,269 735,953
general-purpose Patch cluster C-002 1m 32s 8,011 429,035
sdk-python Fix docstring misattribution 1m 27s 3,586 586,269
general-purpose Patch cluster C-004 1m 26s 7,319 1,057,747
general-purpose Patch cluster C-010 1m 24s 8,127 619,571
general-purpose Patch cluster C-001 1m 23s 3,773 1,050,034
general-purpose Patch cluster C-007 1m 22s 6,630 778,254
general-purpose Patch cluster C-004 1m 20s 7,962 711,019
general-purpose Patch cluster C-004 1m 20s 4,856 950,803
general-purpose Patch cluster C-002 1m 20s 5,547 1,052,455
general-purpose Patch cluster C-008 1m 19s 5,858 431,101
sdk-php SDK-2 GREEN implementation 1m 19s 3,755 1,380,581
general-purpose Patch cluster C-011 1m 19s 7,154 815,041
general-purpose Patch cluster C-008 1m 18s 7,310 722,256
sdk-php SDK-1 GREEN implementation 1m 17s 5,813 1,164,329
general-purpose Patch cluster C-004 1m 17s 6,299 531,036
general-purpose Patch cluster C-007 1m 15s 6,472 941,342
general-purpose Patch cluster C-009 1m 14s 4,952 838,235
general-purpose Patch cluster C-003 1m 13s 4,440 1,141,664
general-purpose Patch cluster C-006 1m 12s 6,961 525,336
general-purpose Patch cluster C-001 1m 12s 5,397 1,328,768
tracking-dev DOC-1 shared docs edits 1m 10s 7,246 759,006
general-purpose Patch cluster C-001 1m 10s 6,322 901,977
general-purpose Patch cluster C-007 1m 09s 7,120 531,184
general-purpose Patch cluster C-003 1m 04s 5,450 429,649
general-purpose Patch cluster C-006 1m 04s 6,209 522,270
general-purpose Patch cluster C-010 1m 01s 3,893 951,913
sdk-php Interface docblock fix 1m 01s 2,909 930,376
general-purpose Patch cluster C-002 1m 01s 5,298 627,318
general-purpose Patch cluster C-013 59s 4,838 611,676
general-purpose Patch cluster C-006 59s 5,605 715,996
general-purpose Patch cluster C-002 56s 5,701 613,413
general-purpose Patch cluster C-006 55s 4,167 943,416
general-purpose Patch cluster C-006 55s 3,542 506,193
general-purpose Patch cluster C-001 53s 4,790 711,918
general-purpose Patch cluster C-005 52s 3,917 619,087
general-purpose Patch cluster C-012 52s 4,154 631,183
general-purpose Patch cluster C-002 51s 4,663 808,816
general-purpose Patch cluster C-003 51s 4,314 546,791
general-purpose Patch cluster C-012 50s 3,412 507,252
general-purpose Patch cluster C-005 50s 4,882 530,541
general-purpose Patch cluster C-003 49s 5,013 523,391
general-purpose Patch cluster C-007 48s 5,540 660,163
general-purpose Patch cluster C-007 47s 4,822 439,060
general-purpose Patch cluster C-004 47s 4,519 719,475
sdk-test-writer Fix docstring provenance in test file 47s 3,344 708,044
general-purpose Patch cluster C-006 46s 3,426 747,628
general-purpose Patch cluster C-007 46s 3,401 346,011
general-purpose Patch cluster C-005 45s 3,774 845,897
readiness-assessor Readiness assessment 44s 2,219 599,667
general-purpose Patch cluster C-009 44s 4,467 432,647
general-purpose Patch cluster C-009 43s 4,276 608,253
general-purpose Patch cluster C-008 42s 4,022 528,164
general-purpose Patch cluster C-008 42s 2,856 659,990
general-purpose Patch cluster C-011 40s 2,665 423,959
general-purpose Patch cluster C-003 39s 3,634 514,620
general-purpose Patch cluster C-014 39s 961 424,958
general-purpose Patch cluster C-001 38s 3,027 345,217
general-purpose Patch cluster C-013 38s 2,377 412,505
general-purpose Patch cluster C-005 36s 2,012 492,705
general-purpose Patch cluster C-005 35s 3,446 532,299
general-purpose Patch cluster C-005 35s 3,176 527,485
general-purpose Patch cluster C-008 35s 3,619 428,250
general-purpose Patch cluster C-012 35s 3,149 432,240
general-purpose Patch cluster C-009 34s 2,796 634,067
tracking-dev DOC-1 forceVariationId semantics gap 33s 2,004 614,783
general-purpose Patch cluster C-004 33s 2,685 620,760
general-purpose Patch cluster C-011 32s 2,807 522,444
general-purpose Patch cluster C-001 32s 3,475 429,050
general-purpose Patch cluster C-010 32s 2,716 431,299
general-purpose Patch cluster C-002 32s 3,092 532,400
general-purpose Patch cluster C-015 32s 3,339 357,625
general-purpose Patch cluster C-002 31s 1,895 598,591
general-purpose Patch cluster C-009 30s 2,928 429,617
general-purpose Patch cluster C-006 30s 2,933 515,038
general-purpose Patch cluster C-003 29s 2,629 521,842
general-purpose Patch cluster C-010 28s 2,568 614,169
general-purpose Patch cluster C-008 27s 2,283 520,433
general-purpose Patch cluster C-007 27s 1,993 729,856
general-purpose Patch cluster C-011 27s 2,432 521,874
general-purpose Patch cluster C-009 26s 2,043 519,099
general-purpose Patch cluster C-005 26s 2,344 611,248
tracking-dev Durable vid in tracking script 26s 2,747 444,646
general-purpose Patch cluster C-004 23s 1,851 534,416
tracking-dev Backend doc correction 21s 1,855 473,970
tracking-dev DOC-1 two corrections 19s 2,111 372,833
general-purpose Patch cluster C-006 19s 1,732 342,354
general-purpose Patch cluster C-003 18s 665 418,605
general-purpose Patch cluster C-008 16s 1,509 347,835
tracking-dev DOC-1 positive-form scoping 16s 1,460 372,730
general-purpose Check subagent session id 4s 487 112,714
Total 190 task(s) 19h 54m 50s 4,404,410 658,226,641

By agent

Agent Tasks Time Output Cached
feature-conductor 6 6h 54m 42s 818,555 312,520,673
spec-auditor 9 2h 33m 03s 951,754 20,829,983
general-purpose 98 1h 49m 16s 581,708 66,855,483
sdk-test-writer 20 1h 45m 11s 483,223 83,129,361
cross-scope-auditor 9 1h 29m 17s 449,030 13,005,407
decision-auditor 5 1h 00m 17s 261,978 26,178,676
sdk-python 10 46m 02s 129,412 31,376,608
convert-code-reviewer 4 44m 46s 169,897 24,649,507
tracking-dev 8 43m 53s 46,968 9,614,310
decision-analyst 3 41m 25s 183,162 25,325,776
readiness-assessor 5 30m 59s 142,744 16,101,999
sdk-php 5 24m 09s 82,028 10,315,239
sdk-js 5 15m 53s 42,016 11,355,130
docs-sync 2 9m 40s 33,577 4,855,238
convert:premise-challenger 1 6m 17s 28,358 2,113,251

Time = summed gaps between API responses. A gap after a turn ENDED is a wait on a human or a parent agent and is capped at 120s; a gap mid-turn is the agent generating or running its own tool and is counted up to 1800s, which bounds a hung tool without discarding a long test run. An orchestrator's time OVERLAPS the agents it spawned, so the total counts supervision as well as the work supervised. A task's own last response is followed by no gap, so an n-response task contributes n-1 intervals and its final generation is not counted. The orchestrating session is not a task and is not in the table. Cached is cache reads plus cache writes and is normally most of the prompt, because the same prefix is re-read on every response — it therefore tracks how OFTEN an agent was called as much as how much it handled. The UNCACHED prompt remainder is a small fraction of that, so it is not a column here; --json still carries it. Output excludes nothing. They bill at different rates, so a row is a volume, not a cost; /convert:cost without --per-task prices the run.

abbaseya and others added 9 commits September 15, 2026 18:00
17 failing tests covering CAP-1's precedence fixture, CAP-1's omit-not-pad success
criterion, all seven edge-input rows, config-order-versus-caller-order, and CAP-3's
diagnose_feature agreement. All 17 fail with TypeError: unexpected keyword argument,
which is the correct RED — the capability does not exist.

Pure growth: one new file, no existing test or fixture touched.

Beads: ai-driven-product-dev-kpms

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nose_feature

CAP-1 and CAP-3 of SPEC-per-call-bucketing-attributes. The filter narrows which
declaring experiences are considered, at the evaluation seam one layer above the
bucketing seam (D-5). select_experience gains no parameter.

Set membership over the config's own experience order, never a map over caller keys:
run_feature returns the first experience that resolves, so mapping over the caller's
list would silently make key order set precedence. Absent, an empty sequence and a
bare str all mean every experience — the last needs an explicit isinstance guard,
because a str satisfies Sequence[str] and would otherwise filter on its characters.

diagnose_feature takes the filter and not type_casting (D-7): it carries no variable
map for that flag to act on.

Beads: ai-driven-product-dev-kpms

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 tests covering CAP-2's success fixture on both entry points, the
flag-changes-no-decision field comparison, D-6 truthiness across all four falsy and
three truthy values, run_features carrying the flag to every result, and composition
with experience_keys.

11 fail with TypeError: unexpected keyword argument. The 12th passes deliberately: it
calls run_feature with NO type_casting argument and pins today's cast behaviour, so it
is the behaviour-preserving regression lock rather than an assertion of new behaviour.
Its paired type_casting=False test fails, which is what proves the capability is absent.

Pure growth: one new file, no existing test or fixture touched.

Beads: ai-driven-product-dev-hxj8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CAP-2 of SPEC-per-call-bucketing-attributes. Gates the single point variables are read
off the carrying change. Falsy returns variables_data exactly as the snapshot stores it,
which is lossy rather than more accurate — a json variable comes back as its stored
string. The flag changes no decision: same variation, same features, every FeatureResult
field but variables identical.

D-6: annotated bool, consumed by plain truthiness, matching the shipped enable_tracking
and enable_storage. This differs from the reference SDK, which tests key presence, and
from Ruby, which pins only the boolean false. Consistency inside Python wins.

Not on diagnose_feature (D-7) — it carries no variable map for the flag to act on.

The private _cast_variables helper takes the flag with a True default rather than as a
required positional, so a pre-existing internal test's two-argument call shape still
holds. That test was not edited; changing what it means needs an authority this work
does not have.

Beads: ai-driven-product-dev-hxj8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…surfaces

One declarative table enumerates the eleven per-call controls against the four run_*
entry points and both diagnose_* twins, marking each cell honoured, absent or n/a by
design, with the reason for every deliberate absence carried as data rather than as a
comment. Four reasons, not one: a flag inert on the feature path, a control the package
has on no entry point at all, a feature-scoped control on an experience surface, and
type_casting on a diagnostic that carries no variables for it to act on.

The six signature cases are derived from the table and assert set EQUALITY, so they
fail both when an honoured control goes missing and when an absent one appears — the
second direction being one the spec's per-name form could not have caught. Ten
behavioural cases assert an honoured control's value reaches the evaluation seam.

Both directions were mutation-checked against the real code and reverted.

Beads: ai-driven-product-dev-g9lc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CAP-5 item 12 — the only CAP-5 edit that lands in this repo, and with the Python-only
wiki pages deferred, also how CAP-3 ships discoverable at all. The docstrings are the
surface a Python developer meets through IDE hover, completion and help().

run_feature, run_features and diagnose_feature had no Args: block at all where
run_experience documents every one of its four controls. All three now match that
convention and cover every parameter, not only the new ones.

experience_keys carries the pair a caller gets wrong — empty list means no filter,
unknown keys are skipped, all-unknown omits rather than errors, and caller key order
is ignored because config order decides precedence. type_casting states that only the
cast is skipped and that the uncast form is lossy rather than more accurate.
diagnose_feature says why it does not take type_casting.

One new drift test asserts each signature's parameters match its documented Args:
entries, catching both an undocumented parameter and a stale entry.

Documentation only: the AST is identical once docstrings are stripped.

Beads: ai-driven-product-dev-bq6m

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decision-audit round 2. The uncast branch returned dict(raw_variables) untouched while
the casting-on branch normalises with str(key), so an int-keyed variables_data reached
FeatureResult.variables — a field declared Mapping[str, Any], from a helper declared
-> Dict[str, Any]. mypy cannot see it because it trusts the input annotation; the path
is reachable through SDKConfig(data=...), which is how every test here builds config.

The conductor originally decided this the other way, reasoning that coercing keys would
be a conversion on a path defined to perform none. That was wrong about what str(key)
is: the only cast in the helper is _cast_value, and str(key) is the key-normalisation
idiom applied unconditionally by _variable_types in the same file and by segments.py,
rules.py and tracking/conversions.py. CAP-2's language is about values throughout.

A no-op for every JSON-sourced config. One new test pins str keys under both values of
the flag and was confirmed red against the pre-fix line.

Also restores three sentences that commit 88a1ce2 compressed out of run_feature's
docstring — the resolution condition, the miss-reason enumeration and the for-this-call
qualifier. documentation-surfaces.md item 12 asks for an Args: block to be added, never
for the description to be shortened, and B-G11 was measured not to be the constraint.
The enumeration comes back without 'disabled feature', which was already inaccurate:
FeatureStatus.DISABLED exists in the enum and this SDK never constructs it.

Beads: ai-driven-product-dev-hxj8, ai-driven-product-dev-bq6m

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…filtering

Code review round 1, two findings on the same seam.

resolve_features forwarded the iterable verbatim into each per-feature resolve_feature
call, which re-normalised it every time. Feature #1 consumed the iterator, feature #2
onward saw an empty one, 'keys or None' collapsed that to None, and None means no
filter — so the caller silently got back features they had explicitly excluded.
Measured before the fix: a list filtered correctly, a generator and a map object did
not. mypy rejects a generator at a typed call site, but that is the same protection
already judged insufficient for the bare-str row. Materialising once also removes the
per-feature re-normalisation the old shape paid.

CAP-1 says a feature read 'neither evaluates nor is decided by' excluded experiences.
The code was already right — the membership test runs before select_experience — but a
mutation moving it after bucketing left all 50 new tests green, so nothing defended it.
One spy assertion now does, patched on the features module's own binding of
select_experience rather than context's separate one: a spy on the context namespace
never fires for this path and the assertion would have been vacuously green.

Both fixes confirmed by mutation and reverted; src/ carries only the three-line change.

Beads: ai-driven-product-dev-x7zb, ai-driven-product-dev-wifg

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erbatim

Code review round 2. Commit c5853ae made resolve_features materialise the iterable
before the per-feature loop; two docstrings still said it was forwarded verbatim, one of
them four lines above the code written to break exactly that assumption.

It reads as prose, but context.py's docstrings are what documentation-surfaces.md calls
the only CAP-5 edit that lands in this repo — the surface a developer meets through IDE
hover and help() — so a sentence this change falsified is a spec deliverable carrying a
false claim.

The verbatim wording stays where it is still true: sticky_bucketing and type_casting are
both genuinely forwarded unchanged.

Documentation only — the AST is identical once docstrings are stripped.

Beads: ai-driven-product-dev-x7zb

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread tests/test_per_call_control_matrix.py Dismissed
@abbaseya abbaseya self-assigned this Sep 15, 2026

@JosephSamirL JosephSamirL left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — convertcom/python-sdk PR #62 @ 89a9c54

Reviewer: convert-code-reviewer (independent pass, 2026-09-17). Spot-checked by the session: the stale test-module docstrings and the Optional[Sequence[str]] annotation on all three Context surfaces both verified on disk, as is the spec row's mypy --strict claim.

VERDICT: APPROVED (two IMPORTANT, non-blocking findings)

Summary

Reviewed git diff origin/main...HEAD (6 files, +1047/−8) against SPEC-per-call-bucketing-attributes (CAP-1..5, D-1..D-11, the bucketing-attributes.md edge table) and AgDR-0190/0191/0192/0193/0195. The implementation matches the spec's stated behaviour on every row tested, all five shared cross-SDK semantics hold, the bucketing seam is untouched, and every new assertion was proven to bite by mutation. No CRITICAL findings.

Findings

CRITICAL

None.

IMPORTANT

1. Spec row 7's mypy --strict defence does not exist under the shipped annotation (confidence: 80%)

  • File: src/convert_sdk/context.pyContext.run_feature, Context.run_features, Context.diagnose_feature (experience_keys: Optional[Sequence[str]])
  • Rule: spec ↔ implementation agreement (bucketing-attributes.md, experience_keys edge table, "not a sequence at all" row: "mypy --strict rejects it for a typed caller")
  • Evidence: a probe calling all three surfaces with experience_keys="checkout-experiment" type-checks with zero errors. The same probe against the two pre-existing public key-list parameters (Context.run_custom_segments(segment_keys: list[str]), Context.get_config_entities(keys: list[str])) produced incompatible type "str"; expected "list[str]" for both. AgDR-0190 already records this ("the annotation contributes no compile-time defence at the public boundary") and accepted it for tuple ergonomics, but the spec row still asserts the defence. The runtime guard _normalize_experience_keys (isinstance(experience_keys, str) → None) is present, tested, and mutation-verified, so the dangerous direction (character iteration → every feature missing) is closed. What remains: a typed caller's bare string is silently treated as "no filter" with no IDE or CI signal.
  • Fix (either one, so spec and code agree): annotate the three public Context parameters Optional[List[str]] (keep Sequence[str] on the internal resolve_feature/resolve_features seam), matching the two public precedents; or correct the spec row's "Why" text so it no longer claims a mypy rejection. Not blocking: behaviour is per the table's Behaviour column.

2. Two new test modules carry module docstrings that are false at HEAD (confidence: 85%)

  • Files: tests/test_feature_experience_keys.py (module docstring: "None of these accept the keyword today, so every test below fails with a TypeError … until the capability ships", "Semantics locked in here (not yet implemented)"); tests/test_feature_type_casting.py (same two claims). Also the test_feature_experience_keys.py section banner # --- CAP-2: narrowing omits, never pads DISABLED — that clause is the CAP-1 / "Narrowing omits" constraint; CAP-2 is type_casting.
  • Rule: Code Quality — stale/temporary prose left in. The repo precedent (tests/test_mutual_exclusion.py) says "RED tests" but does not claim the code is unimplemented.
  • Fix: drop the "not yet implemented / fails until ships" sentences (keep "RED" if that is the house marker), and relabel the banner CAP-1.

Shared semantics — verified against code and tests

  • enableTracking suppresses the event only. Untouched by this PR: _record_experience_result gates only track_bucketing; persistence is gated separately by enable_storage in _evaluate_and_record; the decision is returned regardless. The CAP-4 matrix pins enable_tracking/enable_storage absent from the feature pair by signature equality.
  • [] = no filter. _normalize_experience_keys returns None for empty; test test_absent_and_empty_list_both_consider_every_experience[empty-list]. JS reference agrees (FeatureManager.runFeatures: arrayNotEmpty(filter?.experiences) ? getEntities(...) : getEntitiesList(...)).
  • Unknown key skipped, never raised. Set-membership test in resolve_feature; tests test_one_unknown_key_among_known…, test_every_key_unknown_omits….
  • Caller order ignored, config order decides. resolve_feature iterates _experiences_declaring_feature (config order) with set membership; test_reversing_caller_key_order_does_not_change_precedence + test_reversing_config_experience_order_flips_precedence (AgDR-0192's two-test shape assert different winners).
  • Bucketing untouched. evaluation/experiences.py, evaluation/bucketing.py not in the diff; select_experience gains no parameter; tests/parity/ 214 passed on 3.13 and 3.9, fixtures untouched.
  • CAP-3: diagnose_feature forwards experience_keys, not type_casting; parametrized agreement test covers resolved / filtered-out / included / unknown-key.
  • CAP-2: truthiness per D-6 (None, 0, "" disable — documented divergence from JS/Ruby); both _cast_variables branches normalise keys to str (AgDR-0195).
  • Generator footgun: resolve_features materialises once via tuple(...); mutation-verified.

What I checked

  • uv sync --group dev --python 3.13 into a scratchpad venv; uv run ruff check src tests scripts demoAll checks passed; uv run mypy --strictno issues in 47 files; uv run mypy --strict typecheck/serving_config_contract.py → clean.
  • uv run pytest -p no:cacheprovider --cov=convert_sdk --cov-report=term-missing --cov-fail-under=851186 passed, 96.92%; coverage report --include='*/convert_sdk/evaluation/*' --fail-under=95 → 97%.
  • pytest tests/parity → 214 passed. Four new files → 52 passed. Same four + parity under Python 3.9.6 → 266 passed.
  • git diff --numstat origin/main...HEAD -- tests/ → insertions only, zero deletions (B-G12 holds); tests/parity diff empty.
  • Mutation run in a scratch copy, 8 mutations, each killed: drop materialisation; membership check after select_experience; iterate caller keys; [] = no experiences; remove str guard; drop type_casting forwarding in resolve_features; verbatim keys on uncast branch; ignore type_casting entirely. Unmutated copy: 52 passed.
  • mypy probe: bare str accepted on the three new surfaces, rejected on the two list[str] precedents — basis for finding 1.
  • Reference semantics read from javascript-sdk/packages/js-sdk/src/feature-manager.ts (runFeature, runFeatures).
  • Other call sites: resolve_feature/resolve_features/_cast_variables have no callers outside the three threaded ones. No CHANGELOG in repo. README has no per-call parameter table to go stale. Wiki pages left to the drift routine (AgDR-0196).
  • Not run: CI's bounds-check and the Windows/macOS matrix cells.
  • Below threshold: public Context docstrings cite CAP-1/D-7/D-4 identifiers visible via help(); follows the existing run_experience precedent.

@JosephSamirL JosephSamirL left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via /convert:approve. An independent code review ran through /convert:review, and this issues the B-G4 human marker at 89a9c54.

@abbaseya
abbaseya merged commit e5b946f into main Sep 17, 2026
27 checks passed
@abbaseya
abbaseya deleted the feat/per-call-bucketing-attributes branch September 17, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants