Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .test-checker/ledger.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -2079,6 +2079,24 @@
{"ts":"2026-10-02T20:30:43.618Z","run":"r-20261002-162723-b8b5","type":"classified","exec":1,"counts":{"sgtlearn-ensemble-random-sgforest-regressor-py--randomsgforestregressor-check-x-y":{"accepted":47}},"queued":0}
{"ts":"2026-10-02T20:30:43.673Z","run":"r-20261002-162723-b8b5","type":"run_reported","likely_bugs":0,"needs_decision":0}
{"ts":"2026-10-02T20:30:43.740Z","run":"r-20261002-162723-b8b5","type":"promoted","files":["tests/regression/test_sgtlearn_ensemble_random_sgforest_regressor_py__randomsgforestregressor_check_x_y_1.py"],"verified":["sgtlearn/ensemble/random_sgforest_regressor.py"]}
{"ts":"2026-10-03T01:56:55.420Z","run":"r-20261002-215655-a64c","type":"run_started","mode":"bugfix","revisions":{"buggy":"53a5c148d71076030fca81ed23bf8df64780e3d2","fixed":"e2deb3abd3bca202e1e41e008d8db455adf0378a"},"given":{"buggy":"HEAD~1","fixed":"HEAD"}}
{"ts":"2026-10-03T01:56:58.477Z","run":"r-20261002-215655-a64c","type":"target_added","target":"sgtlearn-features-py--feature-dict-to-features","file":"sgtlearn/_features.py","symbol":"_feature_dict_to_features","lines":[67,110],"rev":"fixed","body_sha":"29dc5fb2b59365161dee2e30347b09623388c1b3bb754f4d23d2b54cc95121e2"}
{"ts":"2026-10-03T01:57:15.773Z","run":"r-20261002-215655-a64c","type":"context_set","target":"sgtlearn-features-py--feature-dict-to-features","shingle_ratio":0}
{"ts":"2026-10-03T01:57:15.827Z","run":"r-20261002-215655-a64c","type":"payload_emitted","payload":"p-344a6d25","role":"spec","sha256":"45c0a4372d525e72ba232228bd996a02a80f51be816d5193576977d2afaa126f","target":"sgtlearn-features-py--feature-dict-to-features"}
{"ts":"2026-10-03T01:58:34.269Z","run":"r-20261002-215655-a64c","type":"spec_saved","target":"sgtlearn-features-py--feature-dict-to-features","shingle_ratio":0.049}
{"ts":"2026-10-03T01:58:34.323Z","run":"r-20261002-215655-a64c","type":"bundle_frozen","target":"sgtlearn-features-py--feature-dict-to-features","bundle":"b-a64c-sgtlearn-features-py--feature-dict-to-features"}
{"ts":"2026-10-03T01:58:37.621Z","run":"r-20261002-215655-a64c","type":"payload_emitted","payload":"p-8deb8c0c","role":"writer","sha256":"26903dea080139c423949477eeff1083f8611e8f39581e7ea8c71ce1bf4e3ec5","target":"sgtlearn-features-py--feature-dict-to-features"}
{"ts":"2026-10-03T01:58:55.491Z","type":"hook_blocked","event":"handoff-guard","reason":"internal error: unknown payload p-8deb8c0c"}
{"ts":"2026-10-03T01:59:10.243Z","run":"r-20261002-215910-e7c6","type":"run_started","mode":"bugfix","revisions":{"buggy":"53a5c148d71076030fca81ed23bf8df64780e3d2","fixed":"e2deb3abd3bca202e1e41e008d8db455adf0378a"},"given":{"buggy":"53a5c14","fixed":"e2deb3a"}}
{"ts":"2026-10-03T01:59:10.322Z","run":"r-20261002-215910-e7c6","type":"target_added","target":"sgtlearn-features-py--feature-dict-to-features","file":"sgtlearn/_features.py","symbol":"_feature_dict_to_features","lines":[67,110],"rev":"e2deb3a","body_sha":"29dc5fb2b59365161dee2e30347b09623388c1b3bb754f4d23d2b54cc95121e2"}
{"ts":"2026-10-03T01:59:22.638Z","run":"r-20261002-215910-e7c6","type":"context_set","target":"sgtlearn-features-py--feature-dict-to-features","shingle_ratio":0}
{"ts":"2026-10-03T01:59:22.696Z","run":"r-20261002-215910-e7c6","type":"spec_saved","target":"sgtlearn-features-py--feature-dict-to-features","shingle_ratio":0.049}
{"ts":"2026-10-03T01:59:22.757Z","run":"r-20261002-215910-e7c6","type":"bundle_frozen","target":"sgtlearn-features-py--feature-dict-to-features","bundle":"b-e7c6-sgtlearn-features-py--feature-dict-to-features"}
{"ts":"2026-10-03T01:59:22.820Z","run":"r-20261002-215910-e7c6","type":"payload_emitted","payload":"p-83941a5c","role":"writer","sha256":"c34e4e92e897e915e7377f88cb87523f573610e80f21906f117a39db56f97abf","target":"sgtlearn-features-py--feature-dict-to-features"}
{"ts":"2026-10-03T02:00:11.337Z","run":"r-20261002-215910-e7c6","type":"tests_submitted","target":"sgtlearn-features-py--feature-dict-to-features","round":0,"role":"writer","payload":"p-83941a5c","files":["test_sgtlearn_features_py__feature_dict_to_features_1.py"]}
{"ts":"2026-10-03T02:01:56.427Z","run":"r-20261002-215910-e7c6","type":"exec_completed","exec":1,"labels":{"buggy":{"pass":21,"failure":2,"load_errors":0},"fixed":{"pass":23,"load_errors":0}}}
{"ts":"2026-10-03T02:01:59.160Z","run":"r-20261002-215910-e7c6","type":"classified","exec":1,"counts":{"sgtlearn-features-py--feature-dict-to-features":{"neutral":21,"effective":2}},"queued":0}
{"ts":"2026-10-03T02:02:21.121Z","run":"r-20261002-215910-e7c6","type":"run_reported","likely_bugs":0,"needs_decision":0}
{"ts":"2026-10-03T01:57:33.139Z","run":"r-20261002-215733-b7b8","type":"run_started","mode":"bugfix","revisions":{"buggy":"53a5c148d71076030fca81ed23bf8df64780e3d2","fixed":"676c746e8e2c52431adb7bb362fb3c2428833a4f"},"given":{"buggy":"HEAD","fixed":"WORKTREE"}}
{"ts":"2026-10-03T01:57:36.583Z","run":"r-20261002-215733-b7b8","type":"target_added","target":"sgtlearn-export-py--draw-leaf-text","file":"sgtlearn/_export.py","symbol":"_draw_leaf_text","lines":[685,765],"rev":"fixed","body_sha":"faf7044cbc68687fcfa0ea5b13ea538a606b5779d860a71be10b8c0c9ccb09a1"}
{"ts":"2026-10-03T01:58:04.295Z","run":"r-20261002-215733-b7b8","type":"context_set","target":"sgtlearn-export-py--draw-leaf-text","shingle_ratio":0}
Expand Down
46 changes: 46 additions & 0 deletions .test-checker/reports/r-20261002-215910-e7c6.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"run": "r-20261002-215910-e7c6",
"mode": "bugfix",
"revisions": {
"buggy": {
"given": "53a5c14",
"commit": "53a5c148d71076030fca81ed23bf8df64780e3d2"
},
"fixed": {
"given": "e2deb3a",
"commit": "e2deb3abd3bca202e1e41e008d8db455adf0378a"
}
},
"date": "2026-10-03T02:02:21.120Z",
"harness": "claude-code",
"blind_isolation": "strong",
"summary": [
{
"target": "sgtlearn-features-py--feature-dict-to-features",
"effective": 2,
"misguided": 0,
"neutral": 21,
"broken": 0,
"flaky": 0,
"dropped": 0
}
],
"likely_bugs": [],
"suspicions": [
{
"target": "sgtlearn-features-py--feature-dict-to-features",
"analysis": "Logical mistakes: none found for the stated intent. The collision check runs after the uniqueness check and before auto-filling, so a user group is never overwritten; an int key equal to a column some group does list is not a collision. (Int keys sort by their string form, so 10 before 9; this is documented, intended behaviour and out of scope.)\nRobustness omissions: a str key such as \"3\" plus an unlisted column 3 yields duplicate logical names (out of scope per intent: str keys behave as before). Empty groups are accepted. bool keys are not validated. Invalid column references rely on the column-resolution helper to raise."
}
],
"needs_decision": [],
"misguided": [],
"dropped": [],
"promotable": [
{
"file": "tests/tcheck/test_sgtlearn_features_py__feature_dict_to_features_1.py",
"dest": "tests/regression/test_sgtlearn_features_py__feature_dict_to_features_1.py",
"tests": 23
}
],
"not_promotable": []
}
41 changes: 41 additions & 0 deletions .test-checker/reports/r-20261002-215910-e7c6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# test-checker report r-20261002-215910-e7c6

- Mode: bugfix
- buggy: 53a5c14 → `53a5c148d7`
- fixed: e2deb3a → `e2deb3abd3`
- Date: 2026-10-03T02:02:21.120Z
- Harness: claude-code
- Blind isolation: strong

## Summary

| target | effective | misguided | neutral | broken | flaky | dropped |
|---|---|---|---|---|---|---|
| sgtlearn-features-py--feature-dict-to-features | 2 | 0 | 21 | 0 | 0 | 0 |

## Likely bugs

_none_

## Suspicions

### sgtlearn-features-py--feature-dict-to-features

Logical mistakes: none found for the stated intent. The collision check runs after the uniqueness check and before auto-filling, so a user group is never overwritten; an int key equal to a column some group does list is not a collision. (Int keys sort by their string form, so 10 before 9; this is documented, intended behaviour and out of scope.)
Robustness omissions: a str key such as "3" plus an unlisted column 3 yields duplicate logical names (out of scope per intent: str keys behave as before). Empty groups are accepted. bool keys are not validated. Invalid column references rely on the column-resolution helper to raise.

## Needs your decision

_none_

## Misguided tests and what happened to them

_none_

## Dropped

_none_

## Promotable tests

- tests/tcheck/test_sgtlearn_features_py__feature_dict_to_features_1.py → `tests/regression/test_sgtlearn_features_py__feature_dict_to_features_1.py` (23 test(s))
37 changes: 28 additions & 9 deletions sgtlearn/_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class ProcessedFeatures:
logical_names
Parallel names for ``features``. When ``feature_dict`` is supplied,
these are the stringified keys; each omitted column ``i`` is named
``str(i)``. Default (no ``feature_dict``) uses
``str(i)``, or ``\"<i>_1\"`` (``_2``, …) if a key already uses that
name. Default (no ``feature_dict``) uses
``\"0\"`` … ``\"n_features-1\"`` even if ``X`` is a pandas DataFrame
(DataFrame column names are stored on ``feature_names_in_`` instead).
"""
Expand Down Expand Up @@ -85,21 +86,36 @@ def _feature_dict_to_features(
if len(all_idxs) != len(set(all_idxs)):
raise ValueError("Feature indices must be unique")

# (is str key, logical name, columns); int keys and auto-filled columns sort first.
entries = [(isinstance(k, str), str(k), cols) for k, cols in index_dict.items()]
taken = {name for _, name, _ in entries}
if len(taken) != len(entries):
raise ValueError("feature_dict keys must have distinct string forms")

# Auto-fill unlisted columns as singletons named str(i). If a user key
# already has that name, suffix it ("0_1", "0_2", ...) so the user's group
# is never overwritten or shares its name (#81).
listed = set(all_idxs)
for i in range(n_features):
if i not in all_idxs:
index_dict[i] = [i]
if i in listed:
continue
name, n = str(i), 0
while name in taken:
n += 1
name = f"{i}_{n}"
taken.add(name)
entries.append((False, name, [i]))

out: list[FeatureInfoDict] = []
logical_names: list[str] = []
for key in sorted(index_dict.keys(), key=lambda k: (isinstance(k, str), str(k))):
cols = list(index_dict[key])
for _, name, cols in sorted(entries, key=lambda e: (e[0], e[1])):
out.append(
{
"type": "categorical" if len(cols) > 1 else "continuous",
"indices": cols,
"indices": list(cols),
}
)
logical_names.append(str(key))
logical_names.append(name)
return out, tuple(logical_names)


Expand All @@ -121,8 +137,11 @@ def configure_feature_dict(
(``int``) or column names (``str``) when ``column_names`` or a pandas
``DataFrame`` was used for training. A group with more than one column
is categorical; singletons are continuous. Unmentioned columns are
filled in as continuous singletons. When omitted, each column is its
own continuous feature.
filled in as continuous singletons named ``str(i)``; if a key already
uses that name, the column is named ``"<i>_1"`` (then ``_2``, …)
instead. Keys must have distinct string forms (``0`` and ``"0"``
together raise ``ValueError``). When omitted, each column is its own
continuous feature.
column_names
Names of columns in ``X``, used to resolve string column references in
``feature_dict``.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import copy

import pytest

import sgtlearn._features as m


def _simplify(features):
return [(list(f["indices"]), f["type"]) for f in features]


def test_empty_dict_gives_continuous_singletons() -> None:
features, names = m._feature_dict_to_features(3, {})
assert _simplify(features) == [
([0], "continuous"),
([1], "continuous"),
([2], "continuous"),
]
assert names == ("0", "1", "2")


def test_empty_dict_orders_by_string_key() -> None:
features, names = m._feature_dict_to_features(12, {})
expected = sorted(range(12), key=str)
assert names == tuple(str(i) for i in expected)
assert [f["indices"] for f in features] == [[i] for i in expected]


def test_return_types() -> None:
features, names = m._feature_dict_to_features(3, {"g": [0, 1]})
assert isinstance(features, list)
assert isinstance(names, tuple)
assert all(isinstance(n, str) for n in names)
assert all(isinstance(f, dict) for f in features)
for f in features:
assert all(type(i) is int for i in f["indices"])
assert len(features) == len(names)


def test_multi_column_group_is_categorical_and_unlisted_filled() -> None:
features, names = m._feature_dict_to_features(3, {0: [0, 1]})
assert names == ("0", "2")
assert _simplify(features) == [([0, 1], "categorical"), ([2], "continuous")]


def test_single_column_group_is_continuous() -> None:
features, names = m._feature_dict_to_features(2, {"g": [1]})
assert names == ("0", "g")
assert _simplify(features) == [([0], "continuous"), ([1], "continuous")]


def test_str_keys_come_after_int_keys() -> None:
features, names = m._feature_dict_to_features(4, {"a": [1, 2]})
assert names == ("0", "3", "a")
assert _simplify(features) == [
([0], "continuous"),
([3], "continuous"),
([1, 2], "categorical"),
]


def test_str_keys_sorted_lexicographically() -> None:
features, names = m._feature_dict_to_features(4, {"b": [0, 1], "a": [2, 3]})
assert names == ("a", "b")
assert _simplify(features) == [([2, 3], "categorical"), ([0, 1], "categorical")]


def test_indices_keep_user_order() -> None:
features, names = m._feature_dict_to_features(3, {"g": [2, 0, 1]})
assert names == ("g",)
assert features[0]["indices"] == [2, 0, 1]
assert features[0]["type"] == "categorical"


def test_int_keys_ordered_by_string_ten_before_nine() -> None:
features, names = m._feature_dict_to_features(11, {10: [9, 10]})
expected = tuple(sorted([str(i) for i in range(9)] + ["10"]))
assert names == expected
assert names.index("10") < names.index("8")
idx = names.index("10")
assert features[idx]["indices"] == [9, 10]
assert features[idx]["type"] == "categorical"


def test_user_and_auto_int_keys_interleave_by_string() -> None:
features, names = m._feature_dict_to_features(4, {3: [2, 3]})
assert names == ("0", "1", "3")
assert _simplify(features) == [
([0], "continuous"),
([1], "continuous"),
([2, 3], "categorical"),
]


def test_int_key_out_of_range_never_collides() -> None:
features, names = m._feature_dict_to_features(3, {5: [0, 1]})
assert names == ("2", "5")
assert _simplify(features) == [([2], "continuous"), ([0, 1], "categorical")]


def test_int_key_matching_listed_column_is_allowed() -> None:
features, names = m._feature_dict_to_features(3, {1: [1, 2]})
assert names == ("0", "1")
assert _simplify(features) == [([0], "continuous"), ([1, 2], "categorical")]


def test_int_key_matching_unlisted_column_renames_auto_column() -> None:
features, names = m._feature_dict_to_features(3, {1: [0, 2]})
assert names == ("1", "1_1")
assert _simplify(features) == [([0, 2], "categorical"), ([1], "continuous")]


def test_multiple_colliding_keys_each_get_renamed_auto_column() -> None:
features, names = m._feature_dict_to_features(6, {3: [0, 1], 4: [2, 5]})
assert names == ("3", "3_1", "4", "4_1")
assert _simplify(features) == [
([0, 1], "categorical"),
([3], "continuous"),
([2, 5], "categorical"),
([4], "continuous"),
]


def test_str_key_equal_to_auto_name_renames_auto_column() -> None:
features, names = m._feature_dict_to_features(3, {"0": [1, 2]})
assert names == ("0_1", "0")
assert _simplify(features) == [([0], "continuous"), ([1, 2], "categorical")]


def test_duplicate_index_within_group_raises() -> None:
with pytest.raises(ValueError, match="Feature indices must be unique"):
m._feature_dict_to_features(3, {"g": [0, 0]})


def test_duplicate_index_across_groups_raises() -> None:
with pytest.raises(ValueError, match="Feature indices must be unique"):
m._feature_dict_to_features(3, {"a": [0, 1], "b": [1, 2]})


def test_duplicate_via_name_and_index_raises() -> None:
with pytest.raises(ValueError, match="Feature indices must be unique"):
m._feature_dict_to_features(2, {"g": ["a", 0]}, column_names=["a", "b"])


def test_column_names_resolve_to_indices() -> None:
features, names = m._feature_dict_to_features(
3, {"g": ["c", "a"]}, column_names=["a", "b", "c"]
)
assert names == ("1", "g")
assert _simplify(features) == [([1], "continuous"), ([2, 0], "categorical")]


def test_out_of_range_index_error_propagates() -> None:
with pytest.raises(Exception) as ref:
m._resolve_column_index(5, 3, None)
with pytest.raises(type(ref.value)):
m._feature_dict_to_features(3, {"g": [0, 5]})


def test_unknown_name_error_propagates() -> None:
cols = ["a", "b", "c"]
with pytest.raises(Exception) as ref:
m._resolve_column_index("zz", 3, cols)
with pytest.raises(type(ref.value)):
m._feature_dict_to_features(3, {"g": ["a", "zz"]}, column_names=cols)


def test_input_mapping_not_modified() -> None:
fd = {"g": [2, 0], 5: [1, 3]}
snapshot = copy.deepcopy(fd)
m._feature_dict_to_features(5, fd)
assert fd == snapshot


def test_every_column_appears_exactly_once() -> None:
features, names = m._feature_dict_to_features(7, {"x": [6, 1], 0: [0, 3]})
all_idx = [i for f in features for i in f["indices"]]
assert sorted(all_idx) == list(range(7))
assert names == ("0", "2", "4", "5", "x")
Loading
Loading