From a1681e249c093d9c2ea476be53c0cd222d33cda8 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 23 Jun 2026 11:27:13 -0600 Subject: [PATCH] fix(think): gate level:low facets out of activity creation; per-facet descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityStateMachine.update treated every sense facet as activity-worthy and stamped the segment-level activity_summary onto every record. A code-signing segment with five facets produced five activity records — two of them peripheral (level:low) — all sharing the dominant work summary. Two surgical changes in update(): - Exclude any facet whose raw level is exactly "low" when building current_facets, so it is treated identically to an absent facet: it never creates or continues an activity, and a previously-active facet that drops to low winds down through the existing 2-segment facet-gone hysteresis (no new/abrupt end path). Missing/invalid level still normalizes to medium and stays engaged. - Source each record's description from that facet's own facets[i].activity, falling back to activity_summary only when the per-facet text is missing/empty. Forward-behavior only; already-written records are unchanged. level_avg, active_entities, idle/gap/type-change handling, and completed-record shape are untouched for high/medium facets. Tests: new TestLevelGate covers AC1-AC5 (the founder's example facet set, per-facet vs distinct descriptions, summary fallback, drop-to-low hysteresis, all-low wind-down). Reconciled six low->medium fixtures whose intent was multi-facet/pipeline behavior, and three description assertions plus a facet_activity test-helper param so the per-facet description source is proven. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/think/activity_state_machine.py | 17 +- tests/test_activity_state_machine.py | 202 +++++++++++++++++- .../test_activity_state_machine_hysteresis.py | 17 +- tests/test_pipeline_smoke.py | 2 +- tests/test_think_activity.py | 2 +- 5 files changed, 218 insertions(+), 22 deletions(-) diff --git a/solstone/think/activity_state_machine.py b/solstone/think/activity_state_machine.py index 5efdf6f5d..47a5798ab 100644 --- a/solstone/think/activity_state_machine.py +++ b/solstone/think/activity_state_machine.py @@ -170,8 +170,14 @@ class ActivityStateMachine: facet_map = {} for facet in raw_facets: - if isinstance(facet, dict) and facet.get("facet"): - facet_map[facet["facet"]] = facet + if not (isinstance(facet, dict) and facet.get("facet")): + continue + # An explicit level:"low" facet is treated exactly like an absent + # facet: it never creates/continues an activity and any prior active + # activity winds down through the facet-gone hysteresis loop below. + if facet.get("level") == "low": + continue + facet_map[facet["facet"]] = facet current_facets = set(facet_map.keys()) # Hysteresis invariant: pending segments bridge into the prior activity's segments[]; @@ -207,6 +213,7 @@ class ActivityStateMachine: level = facet_data.get("level", "medium") if level not in ("high", "medium", "low"): level = "medium" + description = facet_data.get("activity") or activity_summary if facet in self.state: prior = self.state[facet] @@ -238,7 +245,7 @@ class ActivityStateMachine: "activity": content_type, "state": "active", "since": segment_key, - "description": activity_summary, + "description": description, "level": level, "active_entities": entity_names, "_change": "new", @@ -260,7 +267,7 @@ class ActivityStateMachine: prior["segments"].append(segment_key) changes.append(dict(prior)) else: - prior["description"] = activity_summary + prior["description"] = description prior["level"] = level prior["active_entities"] = entity_names prior["_pending_facet_misses"] = 0 @@ -278,7 +285,7 @@ class ActivityStateMachine: "activity": content_type, "state": "active", "since": segment_key, - "description": activity_summary, + "description": description, "level": level, "active_entities": entity_names, "_change": "new", diff --git a/tests/test_activity_state_machine.py b/tests/test_activity_state_machine.py index 2f4cd9821..6ac60c0c6 100644 --- a/tests/test_activity_state_machine.py +++ b/tests/test_activity_state_machine.py @@ -9,13 +9,14 @@ def _sense( density="active", facets=None, summary="Working on code.", + facet_activity="Reviewing the open diff.", entities=None, meeting=False, speakers=None, ): """Build a Sense output payload for testing.""" if facets is None: - facets = [{"facet": "work", "activity": content_type, "level": "high"}] + facets = [{"facet": "work", "activity": facet_activity, "level": "high"}] return { "density": density, "content_type": content_type, @@ -49,12 +50,16 @@ class TestContinuation: sm = ActivityStateMachine() sm.update(_sense(), "090000_300", "20260304") - changes = sm.update(_sense(summary="Still coding."), "090500_300", "20260304") + changes = sm.update( + _sense(facet_activity="Still reviewing the diff."), + "090500_300", + "20260304", + ) assert len(changes) == 1 assert changes[0]["_change"] == "continuing" assert changes[0]["since"] == "090000_300" - assert changes[0]["description"] == "Still coding." + assert changes[0]["description"] == "Still reviewing the diff." class TestContentTypeChange: @@ -135,7 +140,7 @@ class TestMultiFacet: sm = ActivityStateMachine() facets = [ {"facet": "work", "activity": "coding", "level": "high"}, - {"facet": "personal", "activity": "browsing", "level": "low"}, + {"facet": "personal", "activity": "browsing", "level": "medium"}, ] changes = sm.update(_sense(facets=facets), "090000_300", "20260304") @@ -144,6 +149,189 @@ class TestMultiFacet: assert facet_names == {"work", "personal"} +class TestLevelGate: + def test_explicit_low_facets_are_excluded_from_active_set(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + facets = [ + {"facet": "solpbc", "activity": "Planning launch work.", "level": "high"}, + { + "facet": "personal", + "activity": "Coordinating a personal errand.", + "level": "medium", + }, + { + "facet": "bluesky", + "activity": "Checking a social thread.", + "level": "low", + }, + { + "facet": "kognova", + "activity": "Skimming background notes.", + "level": "low", + }, + ] + + changes = sm.update( + _sense(facets=facets, summary="Mixed activity summary."), + "090000_300", + "20260304", + ) + + assert {change["facet"] for change in changes} == {"solpbc", "personal"} + assert set(sm.state) == {"solpbc", "personal"} + assert not any( + change.get("facet") in {"bluesky", "kognova"} for change in changes + ) + + def test_description_uses_each_engaged_facets_activity(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + summary = "Segment-level summary for multiple domains." + facets = [ + { + "facet": "solpbc", + "activity": "Reviewing release blockers.", + "level": "high", + }, + { + "facet": "personal", + "activity": "Sorting out afternoon plans.", + "level": "medium", + }, + ] + + changes = sm.update( + _sense(facets=facets, summary=summary), + "090000_300", + "20260304", + ) + + by_facet = {change["facet"]: change for change in changes} + assert by_facet["solpbc"]["description"] == "Reviewing release blockers." + assert by_facet["personal"]["description"] == "Sorting out afternoon plans." + assert all(change["description"] != summary for change in changes) + + def test_description_falls_back_to_summary_when_facet_activity_missing(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + summary = "Segment summary fills missing facet activity." + facets = [ + {"facet": "work", "activity": "", "level": "high"}, + {"facet": "personal", "level": "medium"}, + ] + + changes = sm.update( + _sense(facets=facets, summary=summary), + "090000_300", + "20260304", + ) + + assert {change["facet"] for change in changes} == {"work", "personal"} + assert all(change["description"] == summary for change in changes) + assert all(change["description"] for change in changes) + + def test_one_low_segment_does_not_end_prior_high_facet(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] + low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] + + sm.update(_sense(facets=high), "090000_300", "20260304") + pending = sm.update(_sense(facets=low), "090500_300", "20260304") + + assert len(pending) == 1 + assert pending[0]["_change"] == "facet_gone_pending" + assert pending[0]["facet"] == "work" + assert sm.state["work"]["_pending_facet_misses"] == 1 + assert not any( + change.get("_change") == "ended_facet_gone" for change in pending + ) + + returned = sm.update(_sense(facets=high), "091000_300", "20260304") + + assert len(returned) == 1 + assert returned[0]["_change"] == "continuing" + assert returned[0]["since"] == "090000_300" + assert sm.state["work"]["_pending_facet_misses"] == 0 + assert sm.get_completed_activities() == [] + + def test_two_low_segments_end_prior_high_facet(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] + low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] + + sm.update(_sense(facets=high), "090000_300", "20260304") + sm.update(_sense(facets=low), "090500_300", "20260304") + changes = sm.update(_sense(facets=low), "091000_300", "20260304") + + ended = [ + change for change in changes if change.get("_change") == "ended_facet_gone" + ] + assert len(ended) == 1 + assert ended[0]["state"] == "ended" + assert ended[0]["facet"] == "work" + assert sm.state == {} + assert len(sm.get_completed_activities()) == 1 + + def test_all_low_fresh_segment_creates_no_activity(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + facets = [ + {"facet": "work", "activity": "Light inbox scan.", "level": "low"}, + {"facet": "personal", "activity": "Background reading.", "level": "low"}, + ] + + changes = sm.update( + _sense(facets=facets, summary="Low-attention activity."), + "090000_300", + "20260304", + ) + + assert changes == [] + assert sm.state == {} + assert sm.get_completed_activities() == [] + + def test_all_low_segments_wind_down_prior_active_facet(self): + from solstone.think.activity_state_machine import ActivityStateMachine + + sm = ActivityStateMachine() + high = [ + {"facet": "work", "activity": "Implementing the parser.", "level": "high"} + ] + all_low = [ + {"facet": "work", "activity": "Glancing at parser logs.", "level": "low"}, + { + "facet": "personal", + "activity": "Skimming personal email.", + "level": "low", + }, + ] + + sm.update(_sense(facets=high), "090000_300", "20260304") + pending = sm.update(_sense(facets=all_low), "090500_300", "20260304") + ended_changes = sm.update(_sense(facets=all_low), "091000_300", "20260304") + + assert len(pending) == 1 + assert pending[0]["_change"] == "facet_gone_pending" + assert pending[0]["_pending_facet_misses"] == 1 + ended = [ + change + for change in ended_changes + if change.get("_change") == "ended_facet_gone" + ] + assert len(ended) == 1 + assert ended[0]["facet"] == "work" + assert sm.state == {} + + class TestFacetDisappearing: def test_facet_gone_emits_ended(self): from solstone.think.activity_state_machine import ActivityStateMachine @@ -151,7 +339,7 @@ class TestFacetDisappearing: sm = ActivityStateMachine() two_facets = [ {"facet": "work", "activity": "coding", "level": "high"}, - {"facet": "personal", "activity": "browsing", "level": "low"}, + {"facet": "personal", "activity": "browsing", "level": "medium"}, ] sm.update(_sense(facets=two_facets), "090000_300", "20260304") one_facet = [{"facet": "work", "activity": "coding", "level": "high"}] @@ -317,7 +505,7 @@ class TestSegmentAccumulationEdgeCases: sm = ActivityStateMachine() two = [ {"facet": "work", "activity": "coding", "level": "high"}, - {"facet": "personal", "activity": "browsing", "level": "low"}, + {"facet": "personal", "activity": "browsing", "level": "medium"}, ] one = [{"facet": "work", "activity": "coding", "level": "high"}] sm.update(_sense(facets=two), "090000_300", "20260304") @@ -408,7 +596,7 @@ class TestSegmentAccumulationEdgeCases: sm = ActivityStateMachine() two = [ {"facet": "work", "activity": "coding", "level": "high"}, - {"facet": "personal", "activity": "browsing", "level": "low"}, + {"facet": "personal", "activity": "browsing", "level": "medium"}, ] sm.update(_sense(facets=two), "090000_300", "20260304") sm.update(_sense(facets=two), "090500_300", "20260304") diff --git a/tests/test_activity_state_machine_hysteresis.py b/tests/test_activity_state_machine_hysteresis.py index ac3e86f7c..78d3783ec 100644 --- a/tests/test_activity_state_machine_hysteresis.py +++ b/tests/test_activity_state_machine_hysteresis.py @@ -17,10 +17,11 @@ def _sense( density: str = "active", facets: list[dict] | None = None, summary: str = "Working on code.", + facet_activity: str = "Reviewing the open diff.", entities: list[dict] | None = None, ) -> dict: if facets is None: - facets = [{"facet": "work", "activity": content_type, "level": "high"}] + facets = [{"facet": "work", "activity": facet_activity, "level": "high"}] return { "density": density, "content_type": content_type, @@ -86,21 +87,21 @@ def test_two_segment_facet_gone_ends_at_k(): def test_single_segment_type_wobble_does_not_end(): sm = ActivityStateMachine() - sm.update(_sense(summary="Writing code."), "090000_300", DAY) + sm.update(_sense(facet_activity="Drafting the parser."), "090000_300", DAY) - wobble = sm.update( - _sense(content_type="meeting", summary="Stand-up."), "090500_300", DAY - ) + wobble = sm.update(_sense(content_type="meeting"), "090500_300", DAY) assert not any(change.get("_change") == "ended_type_change" for change in wobble) assert wobble[0]["_change"] == "type_change_pending" - assert sm.state["work"]["description"] == "Writing code." + assert sm.state["work"]["description"] == "Drafting the parser." assert sm.state["work"]["segments"] == ["090000_300", "090500_300"] - returned = sm.update(_sense(summary="Still coding."), "091000_300", DAY) + returned = sm.update( + _sense(facet_activity="Refining the parser."), "091000_300", DAY + ) assert not any(change.get("_change") == "ended_type_change" for change in returned) assert sm.get_completed_activities() == [] assert sm.state["work"]["activity"] == "coding" - assert sm.state["work"]["description"] == "Still coding." + assert sm.state["work"]["description"] == "Refining the parser." assert sm.state["work"]["_pending_type"] is None assert sm.state["work"]["_pending_type_count"] == 0 assert sm.state["work"]["segments"] == [ diff --git a/tests/test_pipeline_smoke.py b/tests/test_pipeline_smoke.py index 6099ecc92..e40f6936a 100644 --- a/tests/test_pipeline_smoke.py +++ b/tests/test_pipeline_smoke.py @@ -136,7 +136,7 @@ SEGMENTS = [ "density": "active", "content_type": "coding", "activity_summary": "Reviewing PR feedback", - "facets": [{"facet": "work", "activity": "coding", "level": "low"}], + "facets": [{"facet": "work", "activity": "coding", "level": "medium"}], "meeting_detected": False, "speakers": [], "recommend": { diff --git a/tests/test_think_activity.py b/tests/test_think_activity.py index a029d3497..156e2e3297 100644 --- a/tests/test_think_activity.py +++ b/tests/test_think_activity.py @@ -1011,7 +1011,7 @@ class TestActivityPersistenceRoundTrip: two = [ {"facet": "work", "activity": "coding", "level": "high"}, - {"facet": "personal", "activity": "browsing", "level": "low"}, + {"facet": "personal", "activity": "browsing", "level": "medium"}, ] sm = ActivityStateMachine() sm.update(self._sense(facets=two), "090000_300", "20260304") -- 2.51.2