cc-context-stats 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/package.json +8 -1
  2. package/scripts/context-stats.sh +1 -1
  3. package/.editorconfig +0 -60
  4. package/.eslintrc.json +0 -35
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  7. package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
  8. package/.github/dependabot.yml +0 -44
  9. package/.github/workflows/ci.yml +0 -294
  10. package/.github/workflows/release.yml +0 -151
  11. package/.pre-commit-config.yaml +0 -74
  12. package/.prettierrc +0 -33
  13. package/.shellcheckrc +0 -10
  14. package/CHANGELOG.md +0 -187
  15. package/CLAUDE.md +0 -66
  16. package/CODE_OF_CONDUCT.md +0 -59
  17. package/CONTRIBUTING.md +0 -240
  18. package/RELEASE_NOTES.md +0 -19
  19. package/SECURITY.md +0 -44
  20. package/TODOS.md +0 -72
  21. package/assets/logo/favicon.svg +0 -19
  22. package/assets/logo/logo-black.svg +0 -24
  23. package/assets/logo/logo-full.svg +0 -40
  24. package/assets/logo/logo-icon.svg +0 -27
  25. package/assets/logo/logo-mark.svg +0 -28
  26. package/assets/logo/logo-white.svg +0 -24
  27. package/assets/logo/logo-wordmark.svg +0 -6
  28. package/config/settings-example.json +0 -7
  29. package/config/settings-node.json +0 -7
  30. package/config/settings-python.json +0 -7
  31. package/docs/ARCHITECTURE.md +0 -128
  32. package/docs/CSV_FORMAT.md +0 -42
  33. package/docs/DEPLOYMENT.md +0 -71
  34. package/docs/DEVELOPMENT.md +0 -161
  35. package/docs/MODEL_INTELLIGENCE.md +0 -396
  36. package/docs/configuration.md +0 -118
  37. package/docs/context-stats.md +0 -143
  38. package/docs/installation.md +0 -255
  39. package/docs/scripts.md +0 -140
  40. package/docs/troubleshooting.md +0 -278
  41. package/images/claude-statusline-token-graph.gif +0 -0
  42. package/images/claude-statusline.png +0 -0
  43. package/images/context-status-dumbzone.png +0 -0
  44. package/images/context-status.png +0 -0
  45. package/images/statusline-detail.png +0 -0
  46. package/images/token-graph.jpeg +0 -0
  47. package/images/token-graph.png +0 -0
  48. package/images/v1.6.1.png +0 -0
  49. package/install +0 -351
  50. package/install.sh +0 -298
  51. package/jest.config.js +0 -11
  52. package/pyproject.toml +0 -115
  53. package/requirements-dev.txt +0 -12
  54. package/scripts/statusline-full.sh +0 -438
  55. package/scripts/statusline-git.sh +0 -88
  56. package/scripts/statusline-minimal.sh +0 -67
  57. package/scripts/statusline.py +0 -569
  58. package/src/claude_statusline/__init__.py +0 -11
  59. package/src/claude_statusline/__main__.py +0 -6
  60. package/src/claude_statusline/cli/__init__.py +0 -1
  61. package/src/claude_statusline/cli/context_stats.py +0 -542
  62. package/src/claude_statusline/cli/explain.py +0 -228
  63. package/src/claude_statusline/cli/statusline.py +0 -184
  64. package/src/claude_statusline/core/__init__.py +0 -1
  65. package/src/claude_statusline/core/colors.py +0 -124
  66. package/src/claude_statusline/core/config.py +0 -165
  67. package/src/claude_statusline/core/git.py +0 -78
  68. package/src/claude_statusline/core/state.py +0 -323
  69. package/src/claude_statusline/formatters/__init__.py +0 -1
  70. package/src/claude_statusline/formatters/layout.py +0 -67
  71. package/src/claude_statusline/formatters/time.py +0 -50
  72. package/src/claude_statusline/formatters/tokens.py +0 -70
  73. package/src/claude_statusline/graphs/__init__.py +0 -1
  74. package/src/claude_statusline/graphs/intelligence.py +0 -162
  75. package/src/claude_statusline/graphs/renderer.py +0 -401
  76. package/src/claude_statusline/graphs/statistics.py +0 -92
  77. package/src/claude_statusline/ui/__init__.py +0 -1
  78. package/src/claude_statusline/ui/icons.py +0 -93
  79. package/src/claude_statusline/ui/waiting.py +0 -62
  80. package/tests/bash/test_delta_parity.bats +0 -199
  81. package/tests/bash/test_install.bats +0 -29
  82. package/tests/bash/test_parity.bats +0 -315
  83. package/tests/bash/test_statusline_full.bats +0 -139
  84. package/tests/bash/test_statusline_git.bats +0 -42
  85. package/tests/bash/test_statusline_minimal.bats +0 -37
  86. package/tests/fixtures/json/comma_in_path.json +0 -31
  87. package/tests/fixtures/json/high_usage.json +0 -17
  88. package/tests/fixtures/json/low_usage.json +0 -17
  89. package/tests/fixtures/json/medium_usage.json +0 -17
  90. package/tests/fixtures/json/valid_full.json +0 -30
  91. package/tests/fixtures/json/valid_minimal.json +0 -9
  92. package/tests/fixtures/mi_test_vectors.json +0 -140
  93. package/tests/node/intelligence.test.js +0 -98
  94. package/tests/node/rotation.test.js +0 -89
  95. package/tests/node/statusline.test.js +0 -240
  96. package/tests/python/conftest.py +0 -84
  97. package/tests/python/test_colors.py +0 -105
  98. package/tests/python/test_config_colors.py +0 -78
  99. package/tests/python/test_data_pipeline.py +0 -446
  100. package/tests/python/test_explain.py +0 -177
  101. package/tests/python/test_icons.py +0 -152
  102. package/tests/python/test_intelligence.py +0 -314
  103. package/tests/python/test_layout.py +0 -127
  104. package/tests/python/test_state_rotation_validation.py +0 -232
  105. package/tests/python/test_statusline.py +0 -215
  106. package/tests/python/test_waiting.py +0 -127
@@ -1,177 +0,0 @@
1
- """Tests for the context-stats explain command."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
- PROJECT_ROOT = Path(__file__).parent.parent.parent
9
- FIXTURES_DIR = PROJECT_ROOT / "tests" / "fixtures" / "json"
10
-
11
-
12
- class TestExplainCommand:
13
- """Tests for `context-stats explain`."""
14
-
15
- def _run_explain(self, input_data, extra_args=None):
16
- """Run context-stats explain with JSON input and return stdout."""
17
- cmd = [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"]
18
- if extra_args:
19
- cmd.extend(extra_args)
20
- result = subprocess.run(
21
- cmd,
22
- input=json.dumps(input_data),
23
- capture_output=True,
24
- text=True,
25
- timeout=10,
26
- )
27
- return result
28
-
29
- def test_explain_shows_model(self):
30
- data = {"model": {"display_name": "Opus 4.5", "id": "claude-opus-4-5"}}
31
- result = self._run_explain(data)
32
- assert result.returncode == 0
33
- assert "Opus 4.5" in result.stdout
34
- assert "claude-opus-4-5" in result.stdout
35
-
36
- def test_explain_shows_workspace(self):
37
- data = {
38
- "workspace": {
39
- "current_dir": "/home/user/project",
40
- "project_dir": "/home/user/project",
41
- }
42
- }
43
- result = self._run_explain(data)
44
- assert result.returncode == 0
45
- assert "/home/user/project" in result.stdout
46
-
47
- def test_explain_shows_context_window(self):
48
- data = {
49
- "context_window": {
50
- "context_window_size": 200000,
51
- "current_usage": {
52
- "input_tokens": 50000,
53
- "cache_creation_input_tokens": 10000,
54
- "cache_read_input_tokens": 20000,
55
- },
56
- }
57
- }
58
- result = self._run_explain(data)
59
- assert result.returncode == 0
60
- assert "200,000" in result.stdout
61
- assert "50,000" in result.stdout
62
- assert "context_used" in result.stdout
63
-
64
- def test_explain_shows_cost(self):
65
- data = {
66
- "cost": {
67
- "total_cost_usd": 0.1234,
68
- "total_lines_added": 100,
69
- "total_lines_removed": 50,
70
- }
71
- }
72
- result = self._run_explain(data)
73
- assert result.returncode == 0
74
- assert "$0.1234" in result.stdout
75
-
76
- def test_explain_shows_session(self):
77
- data = {"session_id": "abc-123", "version": "2.0.0"}
78
- result = self._run_explain(data)
79
- assert result.returncode == 0
80
- assert "abc-123" in result.stdout
81
- assert "2.0.0" in result.stdout
82
-
83
- def test_explain_shows_absent_fields(self):
84
- data = {}
85
- result = self._run_explain(data)
86
- assert result.returncode == 0
87
- assert "(absent)" in result.stdout
88
-
89
- def test_explain_shows_raw_json(self):
90
- data = {"model": {"display_name": "Test"}}
91
- result = self._run_explain(data)
92
- assert result.returncode == 0
93
- assert "Raw JSON" in result.stdout
94
- assert '"display_name": "Test"' in result.stdout
95
-
96
- def test_explain_shows_config(self):
97
- data = {}
98
- result = self._run_explain(data)
99
- assert result.returncode == 0
100
- assert "Active Config" in result.stdout
101
-
102
- def test_explain_with_full_fixture(self):
103
- with open(FIXTURES_DIR / "valid_full.json") as f:
104
- data = json.load(f)
105
- result = self._run_explain(data)
106
- assert result.returncode == 0
107
- assert "Opus 4.5" in result.stdout
108
- assert "test-session-123" in result.stdout
109
-
110
- def test_explain_invalid_json_fails(self):
111
- result = subprocess.run(
112
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"],
113
- input="not valid json",
114
- capture_output=True,
115
- text=True,
116
- timeout=10,
117
- )
118
- assert result.returncode == 1
119
- assert "invalid JSON" in result.stderr
120
-
121
- def test_explain_shows_derived_free_tokens(self):
122
- data = {
123
- "context_window": {
124
- "context_window_size": 200000,
125
- "current_usage": {
126
- "input_tokens": 50000,
127
- "cache_creation_input_tokens": 10000,
128
- "cache_read_input_tokens": 20000,
129
- },
130
- }
131
- }
132
- result = self._run_explain(data)
133
- assert result.returncode == 0
134
- # 200000 - (50000+10000+20000) = 120000
135
- assert "120,000" in result.stdout
136
- assert "60.0%" in result.stdout
137
-
138
- def test_explain_no_color_flag(self):
139
- data = {"model": {"display_name": "Test"}}
140
- result = subprocess.run(
141
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain", "--no-color"],
142
- input=json.dumps(data),
143
- capture_output=True,
144
- text=True,
145
- timeout=10,
146
- )
147
- assert result.returncode == 0
148
- assert "Test" in result.stdout
149
- # No ANSI escape codes when --no-color is passed
150
- assert "\x1b[" not in result.stdout
151
-
152
- def test_explain_shows_vim_mode(self):
153
- data = {"vim": {"mode": "NORMAL"}}
154
- result = self._run_explain(data)
155
- assert result.returncode == 0
156
- assert "NORMAL" in result.stdout
157
- assert "Extensions" in result.stdout
158
-
159
- def test_explain_shows_agent(self):
160
- data = {"agent": {"name": "my-agent"}}
161
- result = self._run_explain(data)
162
- assert result.returncode == 0
163
- assert "my-agent" in result.stdout
164
- assert "Extensions" in result.stdout
165
-
166
- def test_explain_shows_output_style(self):
167
- data = {"output_style": {"name": "concise"}}
168
- result = self._run_explain(data)
169
- assert result.returncode == 0
170
- assert "concise" in result.stdout
171
- assert "Extensions" in result.stdout
172
-
173
- def test_explain_no_extensions_section_when_absent(self):
174
- data = {"model": {"display_name": "Test"}}
175
- result = self._run_explain(data)
176
- assert result.returncode == 0
177
- assert "Extensions" not in result.stdout
@@ -1,152 +0,0 @@
1
- """Tests for activity tier detection."""
2
-
3
- import time
4
-
5
- from claude_statusline.core.state import StateEntry
6
- from claude_statusline.graphs.statistics import detect_spike
7
- from claude_statusline.ui.icons import (
8
- ActivityTier,
9
- get_activity_tier,
10
- get_tier_label,
11
- )
12
-
13
-
14
- def _make_entry(
15
- timestamp: int = 0,
16
- current_input: int = 1000,
17
- cache_creation: int = 0,
18
- cache_read: int = 0,
19
- context_window_size: int = 200_000,
20
- ) -> StateEntry:
21
- """Helper to create a StateEntry for testing."""
22
- return StateEntry(
23
- timestamp=timestamp,
24
- total_input_tokens=current_input,
25
- total_output_tokens=0,
26
- current_input_tokens=current_input,
27
- current_output_tokens=0,
28
- cache_creation=cache_creation,
29
- cache_read=cache_read,
30
- cost_usd=0.0,
31
- lines_added=0,
32
- lines_removed=0,
33
- session_id="test",
34
- model_id="test-model",
35
- workspace_project_dir="/tmp/test",
36
- context_window_size=context_window_size,
37
- )
38
-
39
-
40
- class TestDetectSpike:
41
- """Tests for spike detection logic."""
42
-
43
- def test_empty_deltas(self):
44
- assert detect_spike([], 200_000) is False
45
-
46
- def test_no_spike_small_delta(self):
47
- deltas = [1000, 1200, 1100, 900, 1000]
48
- assert detect_spike(deltas, 200_000) is False
49
-
50
- def test_spike_absolute_threshold(self):
51
- """Delta > 15% of context window is a spike."""
52
- deltas = [1000, 1200, 1100, 900, 35_000]
53
- assert detect_spike(deltas, 200_000) is True
54
-
55
- def test_spike_relative_threshold(self):
56
- """Delta > 3x rolling average is a spike."""
57
- deltas = [100, 100, 100, 100, 500]
58
- assert detect_spike(deltas, 200_000) is True
59
-
60
- def test_no_spike_when_all_similar(self):
61
- deltas = [1000, 1000, 1000, 1000, 1000]
62
- assert detect_spike(deltas, 200_000) is False
63
-
64
- def test_single_delta_no_spike(self):
65
- """Single delta can't be a relative spike (no average to compare)."""
66
- deltas = [1000]
67
- assert detect_spike(deltas, 200_000) is False
68
-
69
- def test_spike_with_zero_context_window(self):
70
- """Only relative threshold applies when context_window is 0."""
71
- deltas = [100, 100, 100, 100, 500]
72
- assert detect_spike(deltas, 0) is True
73
-
74
-
75
- class TestActivityTier:
76
- """Tests for activity tier determination."""
77
-
78
- def test_idle_with_no_entries(self):
79
- assert get_activity_tier([], 200_000) == ActivityTier.IDLE
80
-
81
- def test_idle_with_single_entry(self):
82
- entry = _make_entry(timestamp=int(time.time()))
83
- assert get_activity_tier([entry], 200_000) == ActivityTier.IDLE
84
-
85
- def test_idle_when_stale(self):
86
- """Entries older than 30s should be idle."""
87
- old_time = int(time.time()) - 60
88
- entries = [
89
- _make_entry(timestamp=old_time, current_input=1000),
90
- _make_entry(timestamp=old_time + 5, current_input=2000),
91
- ]
92
- assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
93
-
94
- def test_low_activity(self):
95
- """Small delta (<2% of window) = low."""
96
- now = int(time.time())
97
- entries = [
98
- _make_entry(timestamp=now - 5, current_input=1000),
99
- _make_entry(timestamp=now, current_input=2000), # delta=1000, 0.5% of 200k
100
- ]
101
- assert get_activity_tier(entries, 200_000) == ActivityTier.LOW
102
-
103
- def test_medium_activity(self):
104
- """Delta 2-5% of window = medium."""
105
- now = int(time.time())
106
- entries = [
107
- _make_entry(timestamp=now - 5, current_input=1000),
108
- _make_entry(timestamp=now, current_input=7000), # delta=6000, 3% of 200k
109
- ]
110
- assert get_activity_tier(entries, 200_000) == ActivityTier.MEDIUM
111
-
112
- def test_high_activity(self):
113
- """Delta 5-15% of window = high."""
114
- now = int(time.time())
115
- entries = [
116
- _make_entry(timestamp=now - 5, current_input=1000),
117
- _make_entry(timestamp=now, current_input=21000), # delta=20000, 10% of 200k
118
- ]
119
- assert get_activity_tier(entries, 200_000) == ActivityTier.HIGH
120
-
121
- def test_spike_activity(self):
122
- """Delta > 15% of window = spike."""
123
- now = int(time.time())
124
- entries = [
125
- _make_entry(timestamp=now - 5, current_input=1000),
126
- _make_entry(timestamp=now, current_input=41000), # delta=40000, 20% of 200k
127
- ]
128
- assert get_activity_tier(entries, 200_000) == ActivityTier.SPIKE
129
-
130
- def test_zero_delta_is_idle(self):
131
- """No token change = idle (even if recent)."""
132
- now = int(time.time())
133
- entries = [
134
- _make_entry(timestamp=now - 5, current_input=1000),
135
- _make_entry(timestamp=now, current_input=1000), # delta=0
136
- ]
137
- assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
138
-
139
-
140
- class TestGetTierLabel:
141
- """Tests for tier labels."""
142
-
143
- def test_all_tiers_have_labels(self):
144
- for tier in ActivityTier:
145
- label = get_tier_label(tier)
146
- assert len(label) > 0
147
-
148
- def test_idle_label(self):
149
- assert get_tier_label(ActivityTier.IDLE) == "Idle"
150
-
151
- def test_spike_label(self):
152
- assert get_tier_label(ActivityTier.SPIKE) == "Spike!"
@@ -1,314 +0,0 @@
1
- """Tests for Model Intelligence (MI) score computation."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
-
8
- import pytest
9
-
10
- from claude_statusline.core.state import StateEntry
11
- from claude_statusline.graphs.intelligence import (
12
- MI_GREEN_THRESHOLD,
13
- MI_WEIGHT_CPS,
14
- MI_WEIGHT_ES,
15
- MI_WEIGHT_PS,
16
- MI_YELLOW_THRESHOLD,
17
- IntelligenceScore,
18
- calculate_context_pressure,
19
- calculate_efficiency,
20
- calculate_intelligence,
21
- calculate_productivity,
22
- format_mi_score,
23
- get_mi_color,
24
- )
25
-
26
- FIXTURES_DIR = Path(__file__).parent.parent / "fixtures"
27
-
28
-
29
- def _make_entry(
30
- current_input=0,
31
- cache_creation=0,
32
- cache_read=0,
33
- total_output=0,
34
- lines_added=0,
35
- lines_removed=0,
36
- context_window_size=200000,
37
- ) -> StateEntry:
38
- """Helper to create a StateEntry with sane defaults."""
39
- return StateEntry(
40
- timestamp=1000000,
41
- total_input_tokens=0,
42
- total_output_tokens=total_output,
43
- current_input_tokens=current_input,
44
- current_output_tokens=0,
45
- cache_creation=cache_creation,
46
- cache_read=cache_read,
47
- cost_usd=0.0,
48
- lines_added=lines_added,
49
- lines_removed=lines_removed,
50
- session_id="test",
51
- model_id="test-model",
52
- workspace_project_dir="/test",
53
- context_window_size=context_window_size,
54
- )
55
-
56
-
57
- # --- CPS tests ---
58
-
59
-
60
- class TestContextPressure:
61
- def test_empty_context(self):
62
- assert calculate_context_pressure(0.0) == 1.0
63
-
64
- def test_full_context(self):
65
- assert calculate_context_pressure(1.0) == 0.0
66
-
67
- def test_half_context(self):
68
- cps = calculate_context_pressure(0.5)
69
- assert 0.64 < cps < 0.66 # ~0.646
70
-
71
- def test_custom_beta_linear(self):
72
- cps = calculate_context_pressure(0.5, beta=1.0)
73
- assert cps == pytest.approx(0.5, abs=0.01)
74
-
75
- def test_custom_beta_quadratic(self):
76
- cps = calculate_context_pressure(0.5, beta=2.0)
77
- assert cps == pytest.approx(0.75, abs=0.01)
78
-
79
- def test_over_capacity_clamped(self):
80
- cps = calculate_context_pressure(1.5)
81
- assert cps == 0.0
82
-
83
- def test_negative_utilization(self):
84
- assert calculate_context_pressure(-0.1) == 1.0
85
-
86
-
87
- # --- CPS guard clause ---
88
-
89
-
90
- class TestGuardClause:
91
- def test_zero_context_window(self):
92
- entry = _make_entry(current_input=50000)
93
- score = calculate_intelligence(entry, None, context_window_size=0)
94
- assert score.mi == 1.0
95
- assert score.cps == 1.0
96
- assert score.es == 1.0
97
- assert score.ps == 0.5
98
- assert score.utilization == 0.0
99
-
100
-
101
- # --- ES tests ---
102
-
103
-
104
- class TestEfficiency:
105
- def test_no_tokens(self):
106
- entry = _make_entry()
107
- assert calculate_efficiency(entry) == 1.0
108
-
109
- def test_all_cache_read(self):
110
- entry = _make_entry(cache_read=100000)
111
- assert calculate_efficiency(entry) == 1.0
112
-
113
- def test_no_cache(self):
114
- entry = _make_entry(current_input=100000)
115
- assert calculate_efficiency(entry) == pytest.approx(0.3, abs=0.01)
116
-
117
- def test_mixed_cache(self):
118
- # 60% cache read
119
- entry = _make_entry(current_input=20000, cache_creation=20000, cache_read=60000)
120
- es = calculate_efficiency(entry)
121
- assert es == pytest.approx(0.3 + 0.7 * 0.6, abs=0.01)
122
-
123
-
124
- # --- PS tests ---
125
-
126
-
127
- class TestProductivity:
128
- def test_no_previous_entry(self):
129
- entry = _make_entry(total_output=1000, lines_added=100)
130
- assert calculate_productivity(entry, None) == 0.5
131
-
132
- def test_no_output(self):
133
- prev = _make_entry(total_output=1000)
134
- cur = _make_entry(total_output=1000) # no increase
135
- assert calculate_productivity(cur, prev) == 0.5
136
-
137
- def test_high_productivity(self):
138
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
139
- cur = _make_entry(total_output=100, lines_added=20, lines_removed=5)
140
- ps = calculate_productivity(cur, prev)
141
- # ratio = 25/100 = 0.25, normalized = min(1, 0.25/0.2) = 1.0
142
- assert ps == pytest.approx(1.0, abs=0.01)
143
-
144
- def test_zero_productivity(self):
145
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
146
- cur = _make_entry(total_output=1000, lines_added=0, lines_removed=0)
147
- ps = calculate_productivity(cur, prev)
148
- assert ps == pytest.approx(0.2, abs=0.01)
149
-
150
- def test_moderate_productivity(self):
151
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
152
- cur = _make_entry(total_output=1000, lines_added=50, lines_removed=10)
153
- ps = calculate_productivity(cur, prev)
154
- # ratio = 60/1000 = 0.06, normalized = min(1, 0.06/0.2) = 0.3
155
- assert ps == pytest.approx(0.2 + 0.8 * 0.3, abs=0.01)
156
-
157
- def test_capping(self):
158
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
159
- cur = _make_entry(total_output=10, lines_added=100, lines_removed=100)
160
- ps = calculate_productivity(cur, prev)
161
- # ratio = 200/10 = 20, normalized = min(1, 20/0.2) = 1.0
162
- assert ps == pytest.approx(1.0, abs=0.01)
163
-
164
- def test_consecutive_diffs(self):
165
- """Verify PS uses consecutive entry diffs, not cumulative totals."""
166
- prev = _make_entry(total_output=500, lines_added=50, lines_removed=10)
167
- cur = _make_entry(total_output=600, lines_added=55, lines_removed=12)
168
- ps = calculate_productivity(cur, prev)
169
- # delta_lines = (55-50) + (12-10) = 7, delta_output = 100
170
- # ratio = 7/100 = 0.07, normalized = 0.07/0.2 = 0.35
171
- assert ps == pytest.approx(0.2 + 0.8 * 0.35, abs=0.01)
172
-
173
-
174
- # --- Composite tests ---
175
-
176
-
177
- class TestComposite:
178
- def test_optimal_conditions(self):
179
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
180
- cur = _make_entry(
181
- current_input=1000, cache_read=9000, total_output=100,
182
- lines_added=25, lines_removed=5,
183
- )
184
- score = calculate_intelligence(cur, prev, 200000)
185
- assert score.mi > 0.9
186
-
187
- def test_worst_conditions(self):
188
- prev = _make_entry(total_output=0, lines_added=0, lines_removed=0)
189
- cur = _make_entry(
190
- current_input=200000, total_output=10000,
191
- lines_added=0, lines_removed=0,
192
- )
193
- score = calculate_intelligence(cur, prev, 200000)
194
- assert score.mi < 0.2
195
-
196
- def test_weight_sum(self):
197
- assert MI_WEIGHT_CPS + MI_WEIGHT_ES + MI_WEIGHT_PS == pytest.approx(1.0)
198
-
199
- def test_bounds(self):
200
- """MI should always be in [0, 1]."""
201
- prev = _make_entry(total_output=0)
202
- for u in [0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]:
203
- used = int(u * 200000)
204
- cur = _make_entry(current_input=used, total_output=1000, lines_added=50)
205
- score = calculate_intelligence(cur, prev, 200000)
206
- assert 0.0 <= score.mi <= 1.0, f"MI out of bounds at u={u}: {score.mi}"
207
-
208
-
209
- # --- Color tests ---
210
-
211
-
212
- class TestColor:
213
- def test_green(self):
214
- assert get_mi_color(0.8) == "green"
215
-
216
- def test_yellow(self):
217
- assert get_mi_color(0.5) == "yellow"
218
-
219
- def test_red(self):
220
- assert get_mi_color(0.2) == "red"
221
-
222
- def test_boundary_green(self):
223
- assert get_mi_color(MI_GREEN_THRESHOLD + 0.001) == "green"
224
-
225
- def test_boundary_yellow_upper(self):
226
- assert get_mi_color(MI_GREEN_THRESHOLD) == "yellow"
227
-
228
- def test_boundary_yellow_lower(self):
229
- assert get_mi_color(MI_YELLOW_THRESHOLD + 0.001) == "yellow"
230
-
231
- def test_boundary_red(self):
232
- assert get_mi_color(MI_YELLOW_THRESHOLD) == "red"
233
-
234
-
235
- # --- Format tests ---
236
-
237
-
238
- class TestFormat:
239
- def test_two_decimals(self):
240
- assert format_mi_score(0.82) == "0.82"
241
-
242
- def test_zero(self):
243
- assert format_mi_score(0.0) == "0.00"
244
-
245
- def test_one(self):
246
- assert format_mi_score(1.0) == "1.00"
247
-
248
- def test_rounding(self):
249
- assert format_mi_score(0.8249) == "0.82"
250
- assert format_mi_score(0.8251) == "0.83"
251
-
252
-
253
- # --- Shared test vectors ---
254
-
255
-
256
- class TestSharedVectors:
257
- """Test against shared vectors for cross-implementation parity."""
258
-
259
- @pytest.fixture
260
- def vectors(self):
261
- with open(FIXTURES_DIR / "mi_test_vectors.json") as f:
262
- return json.load(f)
263
-
264
- def test_all_vectors(self, vectors):
265
- for vec in vectors:
266
- inp = vec["input"]
267
- exp = vec["expected"]
268
-
269
- # Build entries from vector input
270
- current_input = inp["current_input"]
271
- cache_creation = inp["cache_creation"]
272
- cache_read = inp["cache_read"]
273
- # current_used should equal current_input + cache_creation + cache_read
274
- # but we trust the vector's current_used for the entry construction
275
- cur = _make_entry(
276
- current_input=current_input,
277
- cache_creation=cache_creation,
278
- cache_read=cache_read,
279
- total_output=inp["cur_output"],
280
- lines_added=inp["cur_lines_added"],
281
- lines_removed=inp["cur_lines_removed"],
282
- context_window_size=inp["context_window"],
283
- )
284
-
285
- has_prev = inp["prev_output"] is not None
286
- if has_prev:
287
- prev = _make_entry(
288
- total_output=inp["prev_output"],
289
- lines_added=inp["prev_lines_added"],
290
- lines_removed=inp["prev_lines_removed"],
291
- )
292
- else:
293
- prev = None
294
-
295
- score = calculate_intelligence(
296
- cur, prev, inp["context_window"], inp["beta"]
297
- )
298
-
299
- assert score.cps == pytest.approx(exp["cps"], abs=0.01), (
300
- f"CPS mismatch for '{vec['description']}': "
301
- f"got {score.cps}, expected {exp['cps']}"
302
- )
303
- assert score.es == pytest.approx(exp["es"], abs=0.01), (
304
- f"ES mismatch for '{vec['description']}': "
305
- f"got {score.es}, expected {exp['es']}"
306
- )
307
- assert score.ps == pytest.approx(exp["ps"], abs=0.01), (
308
- f"PS mismatch for '{vec['description']}': "
309
- f"got {score.ps}, expected {exp['ps']}"
310
- )
311
- assert score.mi == pytest.approx(exp["mi"], abs=0.01), (
312
- f"MI mismatch for '{vec['description']}': "
313
- f"got {score.mi}, expected {exp['mi']}"
314
- )