elbow-helper 0.1.2__tar.gz

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 (49) hide show
  1. elbow_helper-0.1.2/LICENSE +28 -0
  2. elbow_helper-0.1.2/PKG-INFO +269 -0
  3. elbow_helper-0.1.2/README.md +237 -0
  4. elbow_helper-0.1.2/pyproject.toml +59 -0
  5. elbow_helper-0.1.2/setup.cfg +4 -0
  6. elbow_helper-0.1.2/src/elbow_helper/__init__.py +65 -0
  7. elbow_helper-0.1.2/src/elbow_helper/_core_cli.py +321 -0
  8. elbow_helper-0.1.2/src/elbow_helper/api.py +209 -0
  9. elbow_helper-0.1.2/src/elbow_helper/bootstrap.py +135 -0
  10. elbow_helper-0.1.2/src/elbow_helper/candidates.py +88 -0
  11. elbow_helper-0.1.2/src/elbow_helper/cli_argparse.py +308 -0
  12. elbow_helper-0.1.2/src/elbow_helper/cli_click.py +314 -0
  13. elbow_helper-0.1.2/src/elbow_helper/clustering.py +269 -0
  14. elbow_helper-0.1.2/src/elbow_helper/config.py +137 -0
  15. elbow_helper-0.1.2/src/elbow_helper/locator.py +320 -0
  16. elbow_helper-0.1.2/src/elbow_helper/mcp_server.py +100 -0
  17. elbow_helper-0.1.2/src/elbow_helper/metrics.py +102 -0
  18. elbow_helper-0.1.2/src/elbow_helper/multi_criteria.py +46 -0
  19. elbow_helper-0.1.2/src/elbow_helper/multi_fwer.py +160 -0
  20. elbow_helper-0.1.2/src/elbow_helper/multi_pipeline.py +141 -0
  21. elbow_helper-0.1.2/src/elbow_helper/multi_segmentation.py +212 -0
  22. elbow_helper-0.1.2/src/elbow_helper/null_test.py +93 -0
  23. elbow_helper-0.1.2/src/elbow_helper/numerics.py +217 -0
  24. elbow_helper-0.1.2/src/elbow_helper/pipeline.py +190 -0
  25. elbow_helper-0.1.2/src/elbow_helper/plotting.py +1032 -0
  26. elbow_helper-0.1.2/src/elbow_helper/preprocessing.py +312 -0
  27. elbow_helper-0.1.2/src/elbow_helper/search.py +104 -0
  28. elbow_helper-0.1.2/src/elbow_helper/segmented.py +176 -0
  29. elbow_helper-0.1.2/src/elbow_helper/smoothing.py +118 -0
  30. elbow_helper-0.1.2/src/elbow_helper/types.py +426 -0
  31. elbow_helper-0.1.2/src/elbow_helper.egg-info/PKG-INFO +269 -0
  32. elbow_helper-0.1.2/src/elbow_helper.egg-info/SOURCES.txt +47 -0
  33. elbow_helper-0.1.2/src/elbow_helper.egg-info/dependency_links.txt +1 -0
  34. elbow_helper-0.1.2/src/elbow_helper.egg-info/entry_points.txt +4 -0
  35. elbow_helper-0.1.2/src/elbow_helper.egg-info/requires.txt +19 -0
  36. elbow_helper-0.1.2/src/elbow_helper.egg-info/top_level.txt +1 -0
  37. elbow_helper-0.1.2/tests/test_abstention.py +72 -0
  38. elbow_helper-0.1.2/tests/test_api.py +144 -0
  39. elbow_helper-0.1.2/tests/test_auto_shape.py +72 -0
  40. elbow_helper-0.1.2/tests/test_cli_argparse.py +171 -0
  41. elbow_helper-0.1.2/tests/test_cli_click.py +156 -0
  42. elbow_helper-0.1.2/tests/test_locator.py +64 -0
  43. elbow_helper-0.1.2/tests/test_mcp_server.py +172 -0
  44. elbow_helper-0.1.2/tests/test_multiknee.py +265 -0
  45. elbow_helper-0.1.2/tests/test_numerics.py +54 -0
  46. elbow_helper-0.1.2/tests/test_pipeline_synthetic.py +55 -0
  47. elbow_helper-0.1.2/tests/test_preprocessing.py +56 -0
  48. elbow_helper-0.1.2/tests/test_real_world_examples.py +160 -0
  49. elbow_helper-0.1.2/tests/test_stages.py +62 -0
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Warith Harchaoui
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: elbow-helper
3
+ Version: 0.1.2
4
+ Summary: Noise-robust knee/elbow detection: a conservative wrapper around a from-scratch locator that either returns a knee with uncertainty or explicitly abstains.
5
+ Author-email: Warith Harchaoui <warithmetics@deraison.ai>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/warith-harchaoui/elbow-helper
8
+ Keywords: knee,elbow,model-selection,k-means,changepoint
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Classifier: Intended Audience :: Science/Research
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: os-helper>=1.8
18
+ Provides-Extra: cli
19
+ Requires-Dist: click<9,>=8.1; extra == "cli"
20
+ Provides-Extra: api
21
+ Requires-Dist: fastapi<1,>=0.111; extra == "api"
22
+ Requires-Dist: uvicorn<1,>=0.30; extra == "api"
23
+ Provides-Extra: mcp
24
+ Requires-Dist: fastapi-mcp<1,>=0.4; extra == "mcp"
25
+ Requires-Dist: mcp<2,>=1.20; extra == "mcp"
26
+ Requires-Dist: fastapi<1,>=0.111; extra == "mcp"
27
+ Requires-Dist: uvicorn<1,>=0.30; extra == "mcp"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Requires-Dist: httpx>=0.27; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # elbow-helper
34
+
35
+ [πŸ‡«πŸ‡·](https://github.com/warith-harchaoui/elbow-helper/blob/main/LISEZ-MOI.md)&nbsp;&nbsp;|&nbsp;&nbsp;[πŸ‡¬πŸ‡§](https://github.com/warith-harchaoui/elbow-helper/blob/main/README.md)
36
+
37
+ **Noise-robust knee and elbow detection: it reports a knee with uncertainty; it abstains outright otherwise.**
38
+
39
+ ![Elbow Helper Logo](https://raw.githubusercontent.com/warith-harchaoui/elbow-helper/main/assets/logo.png)
40
+
41
+ An algorithm can answer the question "where might a knee or an elbow be?" and it will always return something, even on a straight line or on pure noise. `elbow-helper` wraps a from-scratch knee locator in a conservative decision procedure that answers a harder question instead:
42
+
43
+ > Is any candidate knee strong, unique, persistent, reproducible, and unlikely under a no-knee model? If not, say so.
44
+
45
+ The design priority is to minimise false-positive knees, even at the cost of more abstentions.
46
+
47
+ ## Documentation
48
+
49
+ [πŸ’» Documentation](https://harchaoui.org/warith/ai-helpers/docs/elbow-helper-doc/)
50
+
51
+ [πŸ—ΊοΈ Landscape](https://github.com/warith-harchaoui/elbow-helper/blob/main/LANDSCAPE.md)
52
+
53
+ [πŸ“‹ Examples](https://github.com/warith-harchaoui/elbow-helper/blob/main/EXAMPLES.md)
54
+
55
+ ## Why it exists
56
+
57
+ A knee, on a curve of diminishing returns, marks the point past which extra input buys little extra output: more clusters in k-means, more iterations in an optimiser, more budget on a marketing channel. Existing knee-detection heuristics are excellent at proposing where that point sits, but they carry no notion of confidence. A single point estimate on a noisy curve is easy to over-trust in practice. This package turns that point estimate into a decision backed by evidence. It refuses to answer when the evidence is weak.
58
+
59
+ ## Dependencies
60
+
61
+ The whole package depends on `numpy` and [`os-helper`](https://github.com/warith-harchaoui/os-helper) only: nothing else, not even for the diagnostic figure. The knee-locating algorithm is implemented from scratch, in NumPy only, so there is no `scipy`, `scikit-learn`, `statsmodels`, or `joblib` runtime dependency. `elbow_helper.plotting` writes hand-authored SVG (see Diagnostics below) rather than reaching for matplotlib, so it needs no extra install. See Acknowledgements below for the implementation this algorithm follows.
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install -e . # everything: numpy + os-helper, diagnostics included
67
+ pip install -e ".[dev]" # + pytest
68
+ ```
69
+
70
+ Prefer conda? `conda env create -f environment.yaml && conda activate elbow-helper` sets up Python and pip, then installs from `requirements.txt`, the same dependency list `pyproject.toml` declares.
71
+
72
+ ## Quickstart
73
+
74
+ See [`EXAMPLES.md`](https://github.com/warith-harchaoui/elbow-helper/blob/main/EXAMPLES.md) for more recipes: the k-means elbow, an explicit abstention, an exponential-saturation curve, the diagnostic figure, the standalone locator, and configuration tuning.
75
+
76
+ ```python
77
+ import numpy as np
78
+ from elbow_helper import robust_knee, RobustKneeConfig
79
+
80
+ x = np.linspace(0, 1, 80)
81
+ y = np.where(x <= 0.3, 3*x, 0.9 + 0.2*(x - 0.3))
82
+ y = y / y.max() + np.random.default_rng(0).normal(0, 0.02, x.size)
83
+
84
+ result = robust_knee(x, y, config=RobustKneeConfig(random_seed=0))
85
+
86
+ if result.is_clear:
87
+ print(result.knee_x, result.ci90, result.detection_rate, result.null_p_value)
88
+ else:
89
+ print("no clear knee:", result.reason)
90
+ ```
91
+
92
+ `curve` and `direction` are optional: left unset, both are inferred from the data (see below) and can still be passed explicitly, e.g. `robust_knee(x, y, curve="concave", direction="increasing", ...)`. `y` is optional too: call `robust_knee(y)` alone and `x` is taken to be the implicit `0, 1, ..., n-1`.
93
+
94
+ For the classic k-means or scree "elbow" (convex, decreasing):
95
+
96
+ ```python
97
+ from elbow_helper import robust_elbow
98
+ result = robust_elbow(k_values, inertia) # curve/direction fixed to convex/decreasing
99
+ ```
100
+
101
+ ## Automatic shape and direction
102
+
103
+ Leaving `curve` or `direction` unset infers them from the cleaned, normalised data. `direction` comes from the sign of the trend between `x` and `y`. `curve` comes from whether the (lightly smoothed) curve lies above or below the straight chord connecting its first and last point: above is concave, below is convex, the mathematical definition, holding for both increasing and decreasing curves. This covers all four concave/convex Γ— increasing/decreasing combinations without asking the caller to name the shape up front. Passing `curve` or `direction` explicitly always overrides the inferred value.
104
+
105
+ ## The contract: a tagged union
106
+
107
+ `robust_knee` always returns one of two types, both subclasses of `KneeResult` and distinguished by `.is_clear`:
108
+
109
+ - `ClearKnee`: `knee_x`, `knee_x_norm`, `knee_index`, `ci90` (a 90% bootstrap interval, in data units), `detection_rate`, `smoothing_window`, `sensitivity`, `prominence`, `slope_contrast`, `bic_improvement`, `null_p_value`, and the full `diagnostics`.
110
+ - `NoClearKnee`: a machine-readable `reason` code plus `diagnostics`.
111
+
112
+ You are forced to handle abstention explicitly: there is no silent fallback to a guess.
113
+
114
+ ## How it decides: the pipeline
115
+
116
+ 1. **Preprocess.** Clean, sort, deduplicate, and robustly normalise the data to the unit square, then screen its global shape with a Spearman correlation (a rank-based measure of how monotonic the curve is) and a magnitude-weighted monotonicity check.
117
+ 2. **Scale-space search.** Run the from-scratch locator across a grid of Gaussian smoothing windows and sensitivities; collect every candidate it proposes.
118
+ 3. **Basic filters.** Reject boundary knees, weak prominence, and a low prominence-to-noise ratio.
119
+ 4. **Persistence clustering.** Keep only knees that recur at a stable location across consecutive smoothing scales and most sensitivities. Abstain if two knees look equally plausible (`MULTIPLE_PLAUSIBLE_KNEES`).
120
+ 5. **Model confirmation.** Require a robust slope change, via a Theil-Sen estimator (a regression method that stays accurate even with outliers), plus a continuous broken-line fit that beats a single straight line on blocked cross-validation and on the BIC (Bayesian Information Criterion, a score that rewards fit while penalising extra parameters).
121
+ 6. **Bootstrap.** Re-run the whole search on an IID residual bootstrap (independent, identically distributed resampling of the leftover noise). The knee must be redetected at least 90% of the time, with a tight, unimodal interval.
122
+ 7. **No-knee null test.** Run a Monte Carlo test against a straight-line null model that carries the accepted model's noise scale. The observed knee must be significant at p ≀ 0.01.
123
+
124
+ Only a candidate that clears every gate becomes a `ClearKnee`.
125
+
126
+ ## Abstention reason codes
127
+
128
+ `INSUFFICIENT_DATA`, `INVALID_INPUT`, `ZERO_RANGE`, `INCOMPATIBLE_GLOBAL_SHAPE`,
129
+ `NO_KNEE_CANDIDATES`, `ALL_CANDIDATES_WEAK`, `NO_PERSISTENT_CLUSTER`,
130
+ `MULTIPLE_PLAUSIBLE_KNEES`, `BOUNDARY_KNEE`, `WEAK_SLOPE_CHANGE`,
131
+ `SEGMENTED_MODEL_NOT_BETTER`, `BOOTSTRAP_UNSTABLE`, `BOOTSTRAP_MULTIMODAL`,
132
+ `NULL_NOT_REJECTED`, `INTERNAL_NUMERICAL_FAILURE`.
133
+
134
+ ## Configuration
135
+
136
+ Every threshold lives in `RobustKneeConfig`, a frozen dataclass; call `config.with_(...)` to change one. All positional thresholds are expressed in normalised x-range units. The shipped defaults suit a first practical prototype: modest replicate counts, so a run finishes in seconds.
137
+
138
+ ```python
139
+ RobustKneeConfig(bootstrap_replicates=100, null_replicates=200)
140
+ # validation-grade:
141
+ config.with_(bootstrap_replicates=500, null_replicates=1000)
142
+ ```
143
+
144
+ A note on these thresholds: they are calibrated, conservative defaults, not universal constants. `cluster_tolerance` and `max_neighbor_shift` sit slightly above the reference plan's 0.05 to absorb the locator's one- or two-sample discretisation jitter at modest sample sizes (n of about 60 to 100). Recalibrate them against your own curve and noise family if needed.
145
+
146
+ ## Diagnostics
147
+
148
+ ```python
149
+ from elbow_helper.plotting import plot_diagnostics
150
+ plot_diagnostics(x, y, curve="concave", direction="increasing", out="diag.svg")
151
+ ```
152
+
153
+ ![Diagnostic figure: a curve rising then flattening, with the located knee marked and its 90% confidence band shaded, next to a compact evidence legend with detection probability, null-model p-value, slope contrast, a BIC-derived posterior model probability, and a worst-case-normalized fit-quality score.](https://raw.githubusercontent.com/warith-harchaoui/elbow-helper/main/assets/diagnostics.svg)
154
+
155
+ Hand-authored SVG, no matplotlib, no extra to install: the diagnostic is a core feature. The curve and its located knee sit next to a compact evidence legend backing the point estimate: the detection probability, the null-model p-value, the slope contrast, a BIC-derived posterior probability (the odds, under Kass & Raftery's Bayes-factor approximation, that the knee model is correct), and a fit-quality score normalized against a deliberately pessimistic worst case rather than the easily-beaten sample mean (see `doc/ELBOW-en.tex` / `doc/ELBOW-fr.tex` for the derivation of both). When the evidence is too weak, the figure switches to an honest abstention state instead: a greyed, dashed curve and the reason, never a marker implying more confidence than the data supports. Pass `language="fr"` for the French chrome text.
156
+
157
+ ## Limitations
158
+
159
+ - The current smoother assumes a regular or near-regular spacing in `x`.
160
+ - Auto-detected `curve` and `direction` need a curve with a real, unambiguous trend; on data too weak or noisy to classify confidently, the pipeline abstains with `INCOMPATIBLE_GLOBAL_SHAPE` rather than guessing, the same gate that already screens explicit `curve`/`direction` arguments.
161
+ - Detection is discretised to sample locations. On modest `n`, the located knee can sit within a few samples of the truth (median error at or below about 5% of the x-range, on the supported synthetic family).
162
+ - The straight-line null and the IID residual bootstrap suit roughly homoscedastic (constant-variance), uncorrelated noise. Wild or moving-block bootstrap variants are future work.
163
+ - No finite-data method is infallible. The targets above hold for the documented simulation family, not for every possible curve.
164
+
165
+ ## Standalone locator
166
+
167
+ The from-scratch locator is usable on its own:
168
+
169
+ ```python
170
+ from elbow_helper import KneeLocator
171
+ kl = KneeLocator(x, y, S=1.0, curve="concave", direction="increasing", online=True)
172
+ kl.knee, kl.all_knees
173
+ ```
174
+
175
+ ## Multiple knees: `robust_knees`
176
+
177
+ `robust_knee` answers "is there one knee?". A curve with several genuine
178
+ regime changes, three pricing tiers on a demand curve, say, needs a
179
+ different question: how many breakpoints does this curve actually have and
180
+ where are they? `robust_knees` (plural) answers that one. It searches over every
181
+ possible number of segments with a dynamic program, scores each candidate
182
+ with a modified BIC (a fit-quality score that penalises extra breakpoints,
183
+ so adding one has to earn its keep), then confirms the winning count with a
184
+ Bonferroni-gated permutation test to hold the false-positive rate down as
185
+ the search space grows.
186
+
187
+ ```python
188
+ import numpy as np
189
+ from elbow_helper import RobustKneesConfig, robust_knees
190
+
191
+ rng = np.random.default_rng(3)
192
+ x = np.linspace(0, 1, 100)
193
+ y = np.piecewise(
194
+ x,
195
+ [x < 0.3, (x >= 0.3) & (x < 0.65), x >= 0.65],
196
+ [lambda t: 3 * t, lambda t: 0.9 + 0.2 * (t - 0.3), lambda t: 0.97 + 2.2 * (t - 0.65)],
197
+ ) + rng.normal(0, 0.02, x.size)
198
+
199
+ result = robust_knees(x, y, config=RobustKneesConfig(random_seed=0, fwer_permutations=200))
200
+ print(result)
201
+ ```
202
+
203
+ ```text
204
+ Knees(k=2, x=[0.2929, 0.6465])
205
+ ```
206
+
207
+ The two breakpoints land close to the curve's true regime changes at 0.3 and
208
+ 0.65. Unlike `robust_knee`, an empty result here is not an abstention: it is
209
+ the pipeline's confident conclusion that the curve has no real breakpoint,
210
+ having survived the same search and false-positive gates a nonempty result
211
+ would have had to survive. Only a preprocessing failure (bad input, too
212
+ little data, zero range) returns `InvalidKnees` instead of `Knees`. See
213
+ `research/multiknee/RESULTS.md` and `doc/ELBOW-en.tex` (sections 5-20) for
214
+ the validation behind this design.
215
+
216
+ ## Mathematics
217
+
218
+ `doc/ELBOW-en.tex` ([πŸ‡«πŸ‡· doc/ELBOW-fr.tex](https://github.com/warith-harchaoui/elbow-helper/blob/main/doc/ELBOW-fr.tex)) derives every formula this package runs: the single-knee pipeline's normalisation, Spearman screen, difference-curve knee search, persistence clustering, Theil-Sen slope, BIC, blocked cross-validation, bootstrap and null test, and the multi-knee research behind `robust_knees` (see also `research/multiknee/RESULTS.md`). It is written intuition-first, with a worked example before every formula, for readers anywhere from the end of high school to a Ph.D. in applied mathematics. Its Gaussian likelihood foundation, the general theory behind why `L := exp(E[log p])` rather than a raw product, and how the same construction reads on a classification model, is factored out into a companion note, `doc/LIKELIHOOD-en.tex` ([πŸ‡«πŸ‡· doc/LIKELIHOOD-fr.tex](https://github.com/warith-harchaoui/elbow-helper/blob/main/doc/LIKELIHOOD-fr.tex)), since that foundation doesn't depend on curve-fitting at all. Citations are in `doc/references.bib`, including a few pointers into my own [Favourite AI books](https://deraison.ai/ai-books) where a technique used here deserves a book-length treatment. Native LaTeX (not Markdown), given the audience: compile with `latexmk -pdf ELBOW-en.tex` (or `ELBOW-fr.tex`, `LIKELIHOOD-en.tex`, `LIKELIHOOD-fr.tex`) from inside `doc/`, or read the compiled copies directly, `doc/ELBOW-en.pdf` / `doc/ELBOW-fr.pdf` / `doc/LIKELIHOOD-en.pdf` / `doc/LIKELIHOOD-fr.pdf`.
219
+
220
+ ## Landscape
221
+
222
+ [πŸ—ΊοΈ Landscape](https://github.com/warith-harchaoui/elbow-helper/blob/main/LANDSCAPE.md) ([πŸ‡«πŸ‡· PAYSAGE.md](https://github.com/warith-harchaoui/elbow-helper/blob/main/PAYSAGE.md)): how `elbow-helper` compares to `kneed`, `ruptures`, `kneebow`, Yellowbrick's `KElbowVisualizer`, R's `segmented` package, manual eyeballing and asking an LLM, rated on 11 criteria and positioned on a PCA map.
223
+
224
+ ## CLI / API / MCP
225
+
226
+ Beyond the Python library, `elbow-helper` exposes three more doors onto the same pipeline: an argparse CLI (always installed), a click CLI twin, and an HTTP API with an MCP server mounted on it. All four are thin adapters over the same shared core (`elbow_helper._core_cli`), so none of them can drift from what the library itself returns.
227
+
228
+ ```bash
229
+ pip install -e . # library + argparse CLI
230
+ pip install -e ".[cli]" # + the click twin
231
+ pip install -e ".[api]" # + the FastAPI HTTP surface
232
+ pip install -e ".[mcp]" # + the MCP server (pulls in [api] too)
233
+ ```
234
+
235
+ ```bash
236
+ # argparse (always available)
237
+ elbow-helper knee --y-values 0,0.1,0.3,0.6,0.85,0.9,0.92,0.93,0.94,0.95
238
+
239
+ # click twin
240
+ elbow-helper-click knee --y-values 0,0.1,0.3,0.6,0.85,0.9,0.92,0.93,0.94,0.95
241
+
242
+ # HTTP API
243
+ uvicorn elbow_helper.api:app --reload
244
+ curl -X POST localhost:8000/knee -d '{"x": [0,0.1,0.3,0.6,0.85,0.9]}'
245
+
246
+ # MCP (fastapi-mcp mounted on the same app, at /mcp)
247
+ uvicorn elbow_helper.mcp_server:app --port 8021
248
+ ```
249
+
250
+ Every surface exposes the same four operations (`knee`, `elbow`, `diagnostics`, `locator`), matching `robust_knee`, `robust_elbow`, `plot_diagnostics`, and the standalone `KneeLocator` one-to-one. Data goes in as inline comma-separated values, a `.npy` file, or a CSV column (CLI), or a JSON body (`x`/`y` lists, HTTP). `RobustKneeConfig` overrides travel as `--config-json '{"bootstrap_replicates": 500}'` (CLI) or a `config_overrides` object (HTTP). The `diagnostics` operation returns the SVG itself, not a JSON wrapper around it.
251
+
252
+ ## Author
253
+
254
+ [Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui)
255
+
256
+ ## Acknowledgements
257
+
258
+ The from-scratch Kneedle implementation in `elbow_helper/locator.py` follows the algorithm described by SatopÀÀ, Albrecht, Irwin and Raghavan (ICDCSW 2011). Its traversal logic, orientation table and sensitivity threshold closely follow the implementation choices of [`kneed`](https://github.com/arvkevi/kneed) by Kevin Arvai, released under the BSD-3-Clause license:
259
+
260
+ > Copyright (c) 2017, Kevin Arvai
261
+ > All rights reserved.
262
+ >
263
+ > Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (1) redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer; (2) redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
264
+
265
+ `elbow-helper` has no runtime dependency on `kneed`: the algorithm is reimplemented in NumPy only, with the scipy calls replaced as documented in `locator.py`.
266
+
267
+ ## License
268
+
269
+ BSD-3-Clause.
@@ -0,0 +1,237 @@
1
+ # elbow-helper
2
+
3
+ [πŸ‡«πŸ‡·](https://github.com/warith-harchaoui/elbow-helper/blob/main/LISEZ-MOI.md)&nbsp;&nbsp;|&nbsp;&nbsp;[πŸ‡¬πŸ‡§](https://github.com/warith-harchaoui/elbow-helper/blob/main/README.md)
4
+
5
+ **Noise-robust knee and elbow detection: it reports a knee with uncertainty; it abstains outright otherwise.**
6
+
7
+ ![Elbow Helper Logo](https://raw.githubusercontent.com/warith-harchaoui/elbow-helper/main/assets/logo.png)
8
+
9
+ An algorithm can answer the question "where might a knee or an elbow be?" and it will always return something, even on a straight line or on pure noise. `elbow-helper` wraps a from-scratch knee locator in a conservative decision procedure that answers a harder question instead:
10
+
11
+ > Is any candidate knee strong, unique, persistent, reproducible, and unlikely under a no-knee model? If not, say so.
12
+
13
+ The design priority is to minimise false-positive knees, even at the cost of more abstentions.
14
+
15
+ ## Documentation
16
+
17
+ [πŸ’» Documentation](https://harchaoui.org/warith/ai-helpers/docs/elbow-helper-doc/)
18
+
19
+ [πŸ—ΊοΈ Landscape](https://github.com/warith-harchaoui/elbow-helper/blob/main/LANDSCAPE.md)
20
+
21
+ [πŸ“‹ Examples](https://github.com/warith-harchaoui/elbow-helper/blob/main/EXAMPLES.md)
22
+
23
+ ## Why it exists
24
+
25
+ A knee, on a curve of diminishing returns, marks the point past which extra input buys little extra output: more clusters in k-means, more iterations in an optimiser, more budget on a marketing channel. Existing knee-detection heuristics are excellent at proposing where that point sits, but they carry no notion of confidence. A single point estimate on a noisy curve is easy to over-trust in practice. This package turns that point estimate into a decision backed by evidence. It refuses to answer when the evidence is weak.
26
+
27
+ ## Dependencies
28
+
29
+ The whole package depends on `numpy` and [`os-helper`](https://github.com/warith-harchaoui/os-helper) only: nothing else, not even for the diagnostic figure. The knee-locating algorithm is implemented from scratch, in NumPy only, so there is no `scipy`, `scikit-learn`, `statsmodels`, or `joblib` runtime dependency. `elbow_helper.plotting` writes hand-authored SVG (see Diagnostics below) rather than reaching for matplotlib, so it needs no extra install. See Acknowledgements below for the implementation this algorithm follows.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install -e . # everything: numpy + os-helper, diagnostics included
35
+ pip install -e ".[dev]" # + pytest
36
+ ```
37
+
38
+ Prefer conda? `conda env create -f environment.yaml && conda activate elbow-helper` sets up Python and pip, then installs from `requirements.txt`, the same dependency list `pyproject.toml` declares.
39
+
40
+ ## Quickstart
41
+
42
+ See [`EXAMPLES.md`](https://github.com/warith-harchaoui/elbow-helper/blob/main/EXAMPLES.md) for more recipes: the k-means elbow, an explicit abstention, an exponential-saturation curve, the diagnostic figure, the standalone locator, and configuration tuning.
43
+
44
+ ```python
45
+ import numpy as np
46
+ from elbow_helper import robust_knee, RobustKneeConfig
47
+
48
+ x = np.linspace(0, 1, 80)
49
+ y = np.where(x <= 0.3, 3*x, 0.9 + 0.2*(x - 0.3))
50
+ y = y / y.max() + np.random.default_rng(0).normal(0, 0.02, x.size)
51
+
52
+ result = robust_knee(x, y, config=RobustKneeConfig(random_seed=0))
53
+
54
+ if result.is_clear:
55
+ print(result.knee_x, result.ci90, result.detection_rate, result.null_p_value)
56
+ else:
57
+ print("no clear knee:", result.reason)
58
+ ```
59
+
60
+ `curve` and `direction` are optional: left unset, both are inferred from the data (see below) and can still be passed explicitly, e.g. `robust_knee(x, y, curve="concave", direction="increasing", ...)`. `y` is optional too: call `robust_knee(y)` alone and `x` is taken to be the implicit `0, 1, ..., n-1`.
61
+
62
+ For the classic k-means or scree "elbow" (convex, decreasing):
63
+
64
+ ```python
65
+ from elbow_helper import robust_elbow
66
+ result = robust_elbow(k_values, inertia) # curve/direction fixed to convex/decreasing
67
+ ```
68
+
69
+ ## Automatic shape and direction
70
+
71
+ Leaving `curve` or `direction` unset infers them from the cleaned, normalised data. `direction` comes from the sign of the trend between `x` and `y`. `curve` comes from whether the (lightly smoothed) curve lies above or below the straight chord connecting its first and last point: above is concave, below is convex, the mathematical definition, holding for both increasing and decreasing curves. This covers all four concave/convex Γ— increasing/decreasing combinations without asking the caller to name the shape up front. Passing `curve` or `direction` explicitly always overrides the inferred value.
72
+
73
+ ## The contract: a tagged union
74
+
75
+ `robust_knee` always returns one of two types, both subclasses of `KneeResult` and distinguished by `.is_clear`:
76
+
77
+ - `ClearKnee`: `knee_x`, `knee_x_norm`, `knee_index`, `ci90` (a 90% bootstrap interval, in data units), `detection_rate`, `smoothing_window`, `sensitivity`, `prominence`, `slope_contrast`, `bic_improvement`, `null_p_value`, and the full `diagnostics`.
78
+ - `NoClearKnee`: a machine-readable `reason` code plus `diagnostics`.
79
+
80
+ You are forced to handle abstention explicitly: there is no silent fallback to a guess.
81
+
82
+ ## How it decides: the pipeline
83
+
84
+ 1. **Preprocess.** Clean, sort, deduplicate, and robustly normalise the data to the unit square, then screen its global shape with a Spearman correlation (a rank-based measure of how monotonic the curve is) and a magnitude-weighted monotonicity check.
85
+ 2. **Scale-space search.** Run the from-scratch locator across a grid of Gaussian smoothing windows and sensitivities; collect every candidate it proposes.
86
+ 3. **Basic filters.** Reject boundary knees, weak prominence, and a low prominence-to-noise ratio.
87
+ 4. **Persistence clustering.** Keep only knees that recur at a stable location across consecutive smoothing scales and most sensitivities. Abstain if two knees look equally plausible (`MULTIPLE_PLAUSIBLE_KNEES`).
88
+ 5. **Model confirmation.** Require a robust slope change, via a Theil-Sen estimator (a regression method that stays accurate even with outliers), plus a continuous broken-line fit that beats a single straight line on blocked cross-validation and on the BIC (Bayesian Information Criterion, a score that rewards fit while penalising extra parameters).
89
+ 6. **Bootstrap.** Re-run the whole search on an IID residual bootstrap (independent, identically distributed resampling of the leftover noise). The knee must be redetected at least 90% of the time, with a tight, unimodal interval.
90
+ 7. **No-knee null test.** Run a Monte Carlo test against a straight-line null model that carries the accepted model's noise scale. The observed knee must be significant at p ≀ 0.01.
91
+
92
+ Only a candidate that clears every gate becomes a `ClearKnee`.
93
+
94
+ ## Abstention reason codes
95
+
96
+ `INSUFFICIENT_DATA`, `INVALID_INPUT`, `ZERO_RANGE`, `INCOMPATIBLE_GLOBAL_SHAPE`,
97
+ `NO_KNEE_CANDIDATES`, `ALL_CANDIDATES_WEAK`, `NO_PERSISTENT_CLUSTER`,
98
+ `MULTIPLE_PLAUSIBLE_KNEES`, `BOUNDARY_KNEE`, `WEAK_SLOPE_CHANGE`,
99
+ `SEGMENTED_MODEL_NOT_BETTER`, `BOOTSTRAP_UNSTABLE`, `BOOTSTRAP_MULTIMODAL`,
100
+ `NULL_NOT_REJECTED`, `INTERNAL_NUMERICAL_FAILURE`.
101
+
102
+ ## Configuration
103
+
104
+ Every threshold lives in `RobustKneeConfig`, a frozen dataclass; call `config.with_(...)` to change one. All positional thresholds are expressed in normalised x-range units. The shipped defaults suit a first practical prototype: modest replicate counts, so a run finishes in seconds.
105
+
106
+ ```python
107
+ RobustKneeConfig(bootstrap_replicates=100, null_replicates=200)
108
+ # validation-grade:
109
+ config.with_(bootstrap_replicates=500, null_replicates=1000)
110
+ ```
111
+
112
+ A note on these thresholds: they are calibrated, conservative defaults, not universal constants. `cluster_tolerance` and `max_neighbor_shift` sit slightly above the reference plan's 0.05 to absorb the locator's one- or two-sample discretisation jitter at modest sample sizes (n of about 60 to 100). Recalibrate them against your own curve and noise family if needed.
113
+
114
+ ## Diagnostics
115
+
116
+ ```python
117
+ from elbow_helper.plotting import plot_diagnostics
118
+ plot_diagnostics(x, y, curve="concave", direction="increasing", out="diag.svg")
119
+ ```
120
+
121
+ ![Diagnostic figure: a curve rising then flattening, with the located knee marked and its 90% confidence band shaded, next to a compact evidence legend with detection probability, null-model p-value, slope contrast, a BIC-derived posterior model probability, and a worst-case-normalized fit-quality score.](https://raw.githubusercontent.com/warith-harchaoui/elbow-helper/main/assets/diagnostics.svg)
122
+
123
+ Hand-authored SVG, no matplotlib, no extra to install: the diagnostic is a core feature. The curve and its located knee sit next to a compact evidence legend backing the point estimate: the detection probability, the null-model p-value, the slope contrast, a BIC-derived posterior probability (the odds, under Kass & Raftery's Bayes-factor approximation, that the knee model is correct), and a fit-quality score normalized against a deliberately pessimistic worst case rather than the easily-beaten sample mean (see `doc/ELBOW-en.tex` / `doc/ELBOW-fr.tex` for the derivation of both). When the evidence is too weak, the figure switches to an honest abstention state instead: a greyed, dashed curve and the reason, never a marker implying more confidence than the data supports. Pass `language="fr"` for the French chrome text.
124
+
125
+ ## Limitations
126
+
127
+ - The current smoother assumes a regular or near-regular spacing in `x`.
128
+ - Auto-detected `curve` and `direction` need a curve with a real, unambiguous trend; on data too weak or noisy to classify confidently, the pipeline abstains with `INCOMPATIBLE_GLOBAL_SHAPE` rather than guessing, the same gate that already screens explicit `curve`/`direction` arguments.
129
+ - Detection is discretised to sample locations. On modest `n`, the located knee can sit within a few samples of the truth (median error at or below about 5% of the x-range, on the supported synthetic family).
130
+ - The straight-line null and the IID residual bootstrap suit roughly homoscedastic (constant-variance), uncorrelated noise. Wild or moving-block bootstrap variants are future work.
131
+ - No finite-data method is infallible. The targets above hold for the documented simulation family, not for every possible curve.
132
+
133
+ ## Standalone locator
134
+
135
+ The from-scratch locator is usable on its own:
136
+
137
+ ```python
138
+ from elbow_helper import KneeLocator
139
+ kl = KneeLocator(x, y, S=1.0, curve="concave", direction="increasing", online=True)
140
+ kl.knee, kl.all_knees
141
+ ```
142
+
143
+ ## Multiple knees: `robust_knees`
144
+
145
+ `robust_knee` answers "is there one knee?". A curve with several genuine
146
+ regime changes, three pricing tiers on a demand curve, say, needs a
147
+ different question: how many breakpoints does this curve actually have and
148
+ where are they? `robust_knees` (plural) answers that one. It searches over every
149
+ possible number of segments with a dynamic program, scores each candidate
150
+ with a modified BIC (a fit-quality score that penalises extra breakpoints,
151
+ so adding one has to earn its keep), then confirms the winning count with a
152
+ Bonferroni-gated permutation test to hold the false-positive rate down as
153
+ the search space grows.
154
+
155
+ ```python
156
+ import numpy as np
157
+ from elbow_helper import RobustKneesConfig, robust_knees
158
+
159
+ rng = np.random.default_rng(3)
160
+ x = np.linspace(0, 1, 100)
161
+ y = np.piecewise(
162
+ x,
163
+ [x < 0.3, (x >= 0.3) & (x < 0.65), x >= 0.65],
164
+ [lambda t: 3 * t, lambda t: 0.9 + 0.2 * (t - 0.3), lambda t: 0.97 + 2.2 * (t - 0.65)],
165
+ ) + rng.normal(0, 0.02, x.size)
166
+
167
+ result = robust_knees(x, y, config=RobustKneesConfig(random_seed=0, fwer_permutations=200))
168
+ print(result)
169
+ ```
170
+
171
+ ```text
172
+ Knees(k=2, x=[0.2929, 0.6465])
173
+ ```
174
+
175
+ The two breakpoints land close to the curve's true regime changes at 0.3 and
176
+ 0.65. Unlike `robust_knee`, an empty result here is not an abstention: it is
177
+ the pipeline's confident conclusion that the curve has no real breakpoint,
178
+ having survived the same search and false-positive gates a nonempty result
179
+ would have had to survive. Only a preprocessing failure (bad input, too
180
+ little data, zero range) returns `InvalidKnees` instead of `Knees`. See
181
+ `research/multiknee/RESULTS.md` and `doc/ELBOW-en.tex` (sections 5-20) for
182
+ the validation behind this design.
183
+
184
+ ## Mathematics
185
+
186
+ `doc/ELBOW-en.tex` ([πŸ‡«πŸ‡· doc/ELBOW-fr.tex](https://github.com/warith-harchaoui/elbow-helper/blob/main/doc/ELBOW-fr.tex)) derives every formula this package runs: the single-knee pipeline's normalisation, Spearman screen, difference-curve knee search, persistence clustering, Theil-Sen slope, BIC, blocked cross-validation, bootstrap and null test, and the multi-knee research behind `robust_knees` (see also `research/multiknee/RESULTS.md`). It is written intuition-first, with a worked example before every formula, for readers anywhere from the end of high school to a Ph.D. in applied mathematics. Its Gaussian likelihood foundation, the general theory behind why `L := exp(E[log p])` rather than a raw product, and how the same construction reads on a classification model, is factored out into a companion note, `doc/LIKELIHOOD-en.tex` ([πŸ‡«πŸ‡· doc/LIKELIHOOD-fr.tex](https://github.com/warith-harchaoui/elbow-helper/blob/main/doc/LIKELIHOOD-fr.tex)), since that foundation doesn't depend on curve-fitting at all. Citations are in `doc/references.bib`, including a few pointers into my own [Favourite AI books](https://deraison.ai/ai-books) where a technique used here deserves a book-length treatment. Native LaTeX (not Markdown), given the audience: compile with `latexmk -pdf ELBOW-en.tex` (or `ELBOW-fr.tex`, `LIKELIHOOD-en.tex`, `LIKELIHOOD-fr.tex`) from inside `doc/`, or read the compiled copies directly, `doc/ELBOW-en.pdf` / `doc/ELBOW-fr.pdf` / `doc/LIKELIHOOD-en.pdf` / `doc/LIKELIHOOD-fr.pdf`.
187
+
188
+ ## Landscape
189
+
190
+ [πŸ—ΊοΈ Landscape](https://github.com/warith-harchaoui/elbow-helper/blob/main/LANDSCAPE.md) ([πŸ‡«πŸ‡· PAYSAGE.md](https://github.com/warith-harchaoui/elbow-helper/blob/main/PAYSAGE.md)): how `elbow-helper` compares to `kneed`, `ruptures`, `kneebow`, Yellowbrick's `KElbowVisualizer`, R's `segmented` package, manual eyeballing and asking an LLM, rated on 11 criteria and positioned on a PCA map.
191
+
192
+ ## CLI / API / MCP
193
+
194
+ Beyond the Python library, `elbow-helper` exposes three more doors onto the same pipeline: an argparse CLI (always installed), a click CLI twin, and an HTTP API with an MCP server mounted on it. All four are thin adapters over the same shared core (`elbow_helper._core_cli`), so none of them can drift from what the library itself returns.
195
+
196
+ ```bash
197
+ pip install -e . # library + argparse CLI
198
+ pip install -e ".[cli]" # + the click twin
199
+ pip install -e ".[api]" # + the FastAPI HTTP surface
200
+ pip install -e ".[mcp]" # + the MCP server (pulls in [api] too)
201
+ ```
202
+
203
+ ```bash
204
+ # argparse (always available)
205
+ elbow-helper knee --y-values 0,0.1,0.3,0.6,0.85,0.9,0.92,0.93,0.94,0.95
206
+
207
+ # click twin
208
+ elbow-helper-click knee --y-values 0,0.1,0.3,0.6,0.85,0.9,0.92,0.93,0.94,0.95
209
+
210
+ # HTTP API
211
+ uvicorn elbow_helper.api:app --reload
212
+ curl -X POST localhost:8000/knee -d '{"x": [0,0.1,0.3,0.6,0.85,0.9]}'
213
+
214
+ # MCP (fastapi-mcp mounted on the same app, at /mcp)
215
+ uvicorn elbow_helper.mcp_server:app --port 8021
216
+ ```
217
+
218
+ Every surface exposes the same four operations (`knee`, `elbow`, `diagnostics`, `locator`), matching `robust_knee`, `robust_elbow`, `plot_diagnostics`, and the standalone `KneeLocator` one-to-one. Data goes in as inline comma-separated values, a `.npy` file, or a CSV column (CLI), or a JSON body (`x`/`y` lists, HTTP). `RobustKneeConfig` overrides travel as `--config-json '{"bootstrap_replicates": 500}'` (CLI) or a `config_overrides` object (HTTP). The `diagnostics` operation returns the SVG itself, not a JSON wrapper around it.
219
+
220
+ ## Author
221
+
222
+ [Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui)
223
+
224
+ ## Acknowledgements
225
+
226
+ The from-scratch Kneedle implementation in `elbow_helper/locator.py` follows the algorithm described by SatopÀÀ, Albrecht, Irwin and Raghavan (ICDCSW 2011). Its traversal logic, orientation table and sensitivity threshold closely follow the implementation choices of [`kneed`](https://github.com/arvkevi/kneed) by Kevin Arvai, released under the BSD-3-Clause license:
227
+
228
+ > Copyright (c) 2017, Kevin Arvai
229
+ > All rights reserved.
230
+ >
231
+ > Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (1) redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer; (2) redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
232
+
233
+ `elbow-helper` has no runtime dependency on `kneed`: the algorithm is reimplemented in NumPy only, with the scipy calls replaced as documented in `locator.py`.
234
+
235
+ ## License
236
+
237
+ BSD-3-Clause.
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "elbow-helper"
7
+ version = "0.1.2"
8
+ description = "Noise-robust knee/elbow detection: a conservative wrapper around a from-scratch locator that either returns a knee with uncertainty or explicitly abstains."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "BSD-3-Clause" }
12
+ authors = [{ name = "Warith Harchaoui", email = "warithmetics@deraison.ai" }]
13
+ keywords = ["knee", "elbow", "model-selection", "k-means", "changepoint"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: BSD License",
17
+ "Topic :: Scientific/Engineering",
18
+ "Intended Audience :: Science/Research",
19
+ ]
20
+
21
+ # Hard dependencies: numpy + os-helper ONLY.
22
+ dependencies = [
23
+ "numpy>=1.24",
24
+ "os-helper>=1.8",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ # Diagnostic plotting (elbow_helper.plotting) is hand-written SVG, stdlib +
29
+ # numpy only β€” no extra to install, no matplotlib. See CHANGELOG [Unreleased].
30
+ # The other three "doors" beyond the library + argparse CLI.
31
+ cli = ["click>=8.1,<9"] # optional click twin (argparse is always on)
32
+ api = ["fastapi>=0.111,<1", "uvicorn>=0.30,<1"]
33
+ # fastapi-mcp mounts the MCP server onto a FastAPI app (see mcp_server.py), so
34
+ # this extra needs the api extra's own deps too. `mcp<2` is pinned because
35
+ # fastapi-mcp 0.4.x calls the mcp SDK's Server() with a positional signature
36
+ # that mcp 2.0 broke (TypeError at import time) β€” the same pin ann-router and
37
+ # other suite members carry; lift it once a fastapi-mcp release compatible
38
+ # with mcp>=2 ships.
39
+ mcp = ["fastapi-mcp>=0.4,<1", "mcp<2,>=1.20", "fastapi>=0.111,<1", "uvicorn>=0.30,<1"]
40
+ dev = ["pytest>=7", "httpx>=0.27"]
41
+
42
+ [project.scripts]
43
+ # argparse CLI β€” no runtime dependency beyond the stdlib + core package.
44
+ elbow-helper = "elbow_helper.cli_argparse:main"
45
+ # click twin β€” install with the [cli] extra.
46
+ elbow-helper-click = "elbow_helper.cli_click:cli"
47
+ # HTTP API + MCP server (needs [mcp]) β€” fastapi-mcp mounts /mcp on the same
48
+ # FastAPI app api.py serves, so one entry point covers both doors.
49
+ elbow-helper-mcp = "elbow_helper.mcp_server:main"
50
+
51
+ [project.urls]
52
+ Homepage = "https://github.com/warith-harchaoui/elbow-helper"
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["src"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+