Skip to content

Commit 8c8062f

Browse files
committed
Standardize config-driven condition weight generation
1 parent 881a73b commit 8c8062f

18 files changed

Lines changed: 252 additions & 5 deletions

File tree

ChangLog.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# psyflow change log
22

3+
## 0.1.19 (2026-03-02)
4+
5+
### Summary
6+
- Added framework-level condition-weight resolver:
7+
- `psyflow.utils.trials.resolve_condition_weights(...)`
8+
- exported via `psyflow.resolve_condition_weights` and `psyflow.utils`.
9+
- Standardized config/contract semantics for weighted condition generation:
10+
- `task.condition_weights` is optional and validated when provided.
11+
- mapping/list formats must align with `task.conditions`; positive numeric weights required.
12+
- omitted/null `task.condition_weights` means even/default generation unless task code documents custom generation.
13+
- Updated cookiecutter template to include optional `task.condition_weights` and pass resolved weights into default condition scheduler.
14+
- Updated task-build skill docs/checklists/templates to enforce explicit `task.condition_weights` when weighted generation is used.
15+
- Added unit coverage for weight resolver behavior in `tests/test_condition_weights.py`.
16+
17+
### Validation
18+
- `python -m unittest tests.test_condition_weights` passed.
19+
- `python -m psyflow.validate "psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}"` passed with `FAIL=0`.
20+
321
## 0.1.18 (2026-03-02)
422

523
### Summary

psyflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"next_trial_id": ("psyflow.utils.trials", "next_trial_id"),
5454
"reset_trial_counter": ("psyflow.utils.trials", "reset_trial_counter"),
5555
"resolve_deadline": ("psyflow.utils.trials", "resolve_deadline"),
56+
"resolve_condition_weights": ("psyflow.utils.trials", "resolve_condition_weights"),
5657
"resolve_trial_id": ("psyflow.utils.trials", "resolve_trial_id"),
5758
}
5859

@@ -91,6 +92,7 @@
9192
next_trial_id as next_trial_id,
9293
reset_trial_counter as reset_trial_counter,
9394
resolve_deadline as resolve_deadline,
95+
resolve_condition_weights as resolve_condition_weights,
9496
resolve_trial_id as resolve_trial_id,
9597
)
9698

psyflow/contracts/v0.1.0/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ These contracts define practical standards for building auditable psyflow/TAPS t
88
- Task metadata (`taskbeacon.yaml`)
99
- Config structure and explicit value/type constraints
1010
- mandatory/optional/recommended keys and value specs
11+
- optional `task.condition_weights` validation (mapping/list aligned to `task.conditions`)
1112
- stimulus type standards and asset-backed path conventions
1213
- smoke-profile rules for `config_qa.yaml` and sim configs (shorter than base but condition-covering)
1314
- Runtime entrypoint pattern (`main.py`)

psyflow/contracts/v0.1.0/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ forbidden_nested_keys:
1616
- sim
1717
optional_nested_keys:
1818
- controller
19+
- task.condition_weights
1920
recommended_nested_keys:
2021
- task.trial_per_block
2122
mandatory_value_specs:
@@ -133,6 +134,8 @@ stim_asset_path_prefixes:
133134
notes:
134135
- controller is optional.
135136
- if controller is configured, keep implementation under src/utils.py.
137+
- if task.condition_weights is defined, weighted condition generation must be explicit and aligned to task.conditions.
138+
- if task.condition_weights is omitted (or null), default/even condition generation is assumed unless a custom generator is documented.
136139
- stimuli entries must include a valid type.
137140
- asset-backed stimuli (image/movie/sound) should load from assets/ paths.
138141
- if an asset-backed stimulus path is set dynamically in runtime code, keep a clear comment in run_trial.py.

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/config/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ task:
4040
total_trials: 24
4141
trial_per_block: 12
4242
conditions: [baseline, variant]
43+
# Optional weighted condition generation; leave null for even generation.
44+
# Use either a mapping by condition label or a list aligned to task.conditions.
45+
condition_weights: null
4346
key_list: [space]
4447
correct_key: space
4548
seed_mode: same_across_sub

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/config/config_qa.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ task:
4141
total_trials: 8
4242
trial_per_block: 8
4343
conditions: [baseline, variant]
44+
# Optional weighted condition generation; leave null for even generation.
45+
# Use either a mapping by condition label or a list aligned to task.conditions.
46+
condition_weights: null
4447
key_list: [space]
4548
correct_key: space
4649
seed_mode: same_across_sub

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/config/config_sampler_sim.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ task:
4141
total_trials: 8
4242
trial_per_block: 8
4343
conditions: [baseline, variant]
44+
# Optional weighted condition generation; leave null for even generation.
45+
# Use either a mapping by condition label or a list aligned to task.conditions.
46+
condition_weights: null
4447
key_list: [space]
4548
correct_key: space
4649
seed_mode: same_across_sub

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/config/config_scripted_sim.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ task:
4141
total_trials: 8
4242
trial_per_block: 8
4343
conditions: [baseline, variant]
44+
# Optional weighted condition generation; leave null for even generation.
45+
# Use either a mapping by condition label or a list aligned to task.conditions.
46+
condition_weights: null
4447
key_list: [space]
4548
correct_key: space
4649
seed_mode: same_across_sub

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
initialize_triggers,
1919
load_config,
2020
parse_task_run_options,
21+
resolve_condition_weights,
2122
runtime_context,
2223
)
2324

@@ -91,6 +92,10 @@ def run(options: TaskRunOptions):
9192
instruction.wait_and_continue()
9293

9394
all_data = []
95+
condition_weights = resolve_condition_weights(
96+
getattr(settings, "condition_weights", None),
97+
list(getattr(settings, "conditions", [])),
98+
)
9499
for block_i in range(settings.total_blocks):
95100
if options.mode not in ("qa", "sim"):
96101
count_down(win, 3, color="black")
@@ -99,6 +104,7 @@ def run(options: TaskRunOptions):
99104
block_idx=block_i,
100105
n_trials=int(settings.trials_per_block),
101106
conditions=list(getattr(settings, "conditions", [])),
107+
condition_weights=condition_weights,
102108
)
103109

104110
block = (

psyflow/templates/cookiecutter-psyflow/{{cookiecutter.project_name}}/src/utils.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,42 @@ def from_dict(cls, config: dict[str, Any] | None) -> "Controller":
3030
merged = {key: raw.get(key, default) for key, default in defaults.items()}
3131
return cls(**merged)
3232

33-
def prepare_block(self, *, block_idx: int, n_trials: int, conditions: list[str] | None) -> list[str]:
33+
def prepare_block(
34+
self,
35+
*,
36+
block_idx: int,
37+
n_trials: int,
38+
conditions: list[str] | None,
39+
condition_weights: list[float] | None = None,
40+
) -> list[str]:
3441
labels = [str(c) for c in (conditions or []) if str(c).strip()]
3542
if not labels:
3643
labels = ["default"]
3744

3845
trial_count = max(1, int(n_trials))
39-
schedule = [labels[i % len(labels)] for i in range(trial_count)]
46+
rng = random.Random(self.seed + int(block_idx) * 1009)
47+
48+
if condition_weights is None:
49+
schedule = [labels[i % len(labels)] for i in range(trial_count)]
50+
else:
51+
if len(condition_weights) != len(labels):
52+
raise ValueError(
53+
"condition_weights length mismatch for labels "
54+
f"{labels}: expected {len(labels)}, got {len(condition_weights)}"
55+
)
56+
total_w = sum(float(w) for w in condition_weights)
57+
raw = [trial_count * float(w) / total_w for w in condition_weights]
58+
counts = [int(x) for x in raw]
59+
rem = trial_count - sum(counts)
60+
if rem > 0:
61+
extra = rng.choices(labels, weights=condition_weights, k=rem)
62+
for lbl in extra:
63+
counts[labels.index(lbl)] += 1
64+
schedule = []
65+
for lbl, cnt in zip(labels, counts):
66+
schedule.extend([lbl] * cnt)
4067

4168
if self.shuffle and len(schedule) > 1:
42-
rng = random.Random(self.seed + int(block_idx) * 1009)
4369
rng.shuffle(schedule)
4470

4571
if self.enable_logging:

0 commit comments

Comments
 (0)