honeyc 0.3.0__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.
- honeyc-0.3.0/PKG-INFO +10 -0
- honeyc-0.3.0/README.md +138 -0
- honeyc-0.3.0/pyproject.toml +23 -0
- honeyc-0.3.0/setup.cfg +4 -0
- honeyc-0.3.0/src/dietc/__init__.py +4 -0
- honeyc-0.3.0/src/dietc/cli.py +74 -0
- honeyc-0.3.0/src/dietc/engine.py +48 -0
- honeyc-0.3.0/src/dietc/gapcap.py +40 -0
- honeyc-0.3.0/src/dietc/load.py +87 -0
- honeyc-0.3.0/src/dietc/models.py +83 -0
- honeyc-0.3.0/src/dietc/patch.py +109 -0
- honeyc-0.3.0/src/dietc/render.py +48 -0
- honeyc-0.3.0/src/dietc/rune.py +83 -0
- honeyc-0.3.0/src/dietc/safety.py +52 -0
- honeyc-0.3.0/src/dietc/vectors.py +137 -0
- honeyc-0.3.0/src/honeyc/__init__.py +9 -0
- honeyc-0.3.0/src/honeyc/ast_nodes.py +73 -0
- honeyc-0.3.0/src/honeyc/check.py +89 -0
- honeyc-0.3.0/src/honeyc/cli.py +79 -0
- honeyc-0.3.0/src/honeyc/emit_cypher.py +82 -0
- honeyc-0.3.0/src/honeyc/emit_prolog.py +51 -0
- honeyc-0.3.0/src/honeyc/grammar.lark +75 -0
- honeyc-0.3.0/src/honeyc/normalize.py +173 -0
- honeyc-0.3.0/src/honeyc/parser.py +144 -0
- honeyc-0.3.0/src/honeyc/render.py +179 -0
- honeyc-0.3.0/src/honeyc/symbols.py +49 -0
- honeyc-0.3.0/src/honeyc.egg-info/PKG-INFO +10 -0
- honeyc-0.3.0/src/honeyc.egg-info/SOURCES.txt +36 -0
- honeyc-0.3.0/src/honeyc.egg-info/dependency_links.txt +1 -0
- honeyc-0.3.0/src/honeyc.egg-info/entry_points.txt +3 -0
- honeyc-0.3.0/src/honeyc.egg-info/requires.txt +6 -0
- honeyc-0.3.0/src/honeyc.egg-info/top_level.txt +2 -0
- honeyc-0.3.0/tests/test_emitters.py +35 -0
- honeyc-0.3.0/tests/test_honeyc_regressions.py +118 -0
- honeyc-0.3.0/tests/test_normalize.py +49 -0
- honeyc-0.3.0/tests/test_parser.py +63 -0
- honeyc-0.3.0/tests/test_readme_notation.py +121 -0
- honeyc-0.3.0/tests/test_render_check.py +51 -0
honeyc-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: honeyc
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A compiler and CLI for Dense Rune-Chain Notation
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: lark>=1.1
|
|
7
|
+
Requires-Dist: click>=8.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
honeyc-0.3.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# HoneyC
|
|
2
|
+
|
|
3
|
+
A compiler and CLI for **Dense Rune-Chain Notation** — a compact symbolic
|
|
4
|
+
notation for entities, relations, types, scopes, transforms, bounded mediator
|
|
5
|
+
objects, placeholders, and agent-executable chains.
|
|
6
|
+
|
|
7
|
+
HoneyC turns dense rune text into an AST, normalized semantic triples, Prolog
|
|
8
|
+
facts, Neo4j Cypher, and human-readable lenses, and it validates chains with
|
|
9
|
+
rewrite suggestions.
|
|
10
|
+
|
|
11
|
+
```txt
|
|
12
|
+
RuneText → AST → Semantic Triples → Prolog → Cypher → Rendered Lenses
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This is the back-end compiler in a larger system: a **rule-catcher** learns an
|
|
16
|
+
emergent notation's grammar from examples; HoneyC compiles a ratified grammar
|
|
17
|
+
into IR + lenses; domain compilers (e.g. DietC, or cognition-chain compilers for
|
|
18
|
+
attention-chains / CoRs / skillchains) sit on top.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python3 -m pip install --no-build-isolation -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
honeyc parse examples/mbr.rune
|
|
30
|
+
honeyc norm examples/mbr.rune
|
|
31
|
+
honeyc prolog examples/mbr.rune
|
|
32
|
+
honeyc cypher examples/mbr.rune
|
|
33
|
+
honeyc render examples/mbr.rune --as readable # also: --as triples | --as prose
|
|
34
|
+
honeyc check examples/mbr.rune
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Core notation
|
|
38
|
+
|
|
39
|
+
One reading of every marker the grammar (`src/honeyc/grammar.lark`) defines. **Triples** are the statements `honeyc
|
|
40
|
+
norm` prints for the **Example**, leaving out each term's own marker statement (`A entity true`, `A symbol true`,
|
|
41
|
+
`🍯 glyph true`) save in the three rows that define them. `tests/test_readme_notation.py` parses every example in this
|
|
42
|
+
table and checks that it gives exactly these triples.
|
|
43
|
+
|
|
44
|
+
| Marker | Reads as | Example | Triples |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `[X]` | a named entity | `[A]` | `A entity true` |
|
|
47
|
+
| `X` | a bare name: a symbol | `A` | `A symbol true` |
|
|
48
|
+
| a glyph | a glyph — a run of characters that are neither structure nor an ASCII name (`symbols.py` names the known ones) | `🍯` | `🍯 glyph true` |
|
|
49
|
+
| `X: T` | `X` has type `T` | `[A]: T` | `A has_type T` |
|
|
50
|
+
| `\|X\|` | `X` is bounded — stabilized, a handleable object | `\|[A]\|` | `A bounded true` |
|
|
51
|
+
| `A ⇔ B` | operational equivalence, both ways | `[A] ⇔ [B]` | `A equiv B` · `B equiv A` |
|
|
52
|
+
| `A ⇒ B` | `A` produces `B` | `[A] ⇒ [B]` | `A produces B` |
|
|
53
|
+
| `A ⇢ B` | `A` compiles to `B` | `[A] ⇢ [B]` | `A compiles_to B` |
|
|
54
|
+
| `A ↦ B` | `A` maps to `B` | `[A] ↦ [B]` | `A maps_to B` |
|
|
55
|
+
| `A → B` | `A` points to `B` | `[A] → [B]` | `A points_to B` |
|
|
56
|
+
| `A ⟶ B` | `A` transitions to `B` | `[A] ⟶ [B]` | `A transitions_to B` |
|
|
57
|
+
| `A ⟼ B` | `A` transforms into `B` at run time | `[A] ⟼ [B]` | `A runtime_transform B` |
|
|
58
|
+
| `A -rel-> B` | the relation `rel(A, B)` | `[A] -owns-> [B]` | `A owns B` |
|
|
59
|
+
| `A <rel- B` | the same relation `rel(A, B)`, written the other way | `[A] <owns- [B]` | `A owns B` |
|
|
60
|
+
| `A <rel> B` | the relation both ways: `rel(A, B)` and `rel(B, A)` | `[A] <owns> [B]` | `A owns B` · `B owns A` |
|
|
61
|
+
| `A:{ B, C }` | a scope: `A` contains `B` and `C` (a `,` separates its members) | `[A]:{ [B], [C] }` | `A contains B` · `A contains C` |
|
|
62
|
+
| `{ B, C }` | a scope with no name: its owner is `_group` | `{ [B], [C] }` | `_group contains B` · `_group contains C` |
|
|
63
|
+
| `📃` | a drilldown placeholder: expand this one level deeper | `[A]:{ 📃 }` | `A placeholder drilldown` |
|
|
64
|
+
| `${A ⇒ B}` | the transform from `A` to `B` as a first-class object, named `xf_` and eight hex digits of a hash of its chain | `${[A] ⇒ [B]}` | `xf_… transform true` · `A produces B` · `xf_… source A` · `xf_… target B` · `xf_… operator produces` |
|
|
65
|
+
| `A := B` | `A` is assigned `B` | `[A] := [B]` | `A assign B` |
|
|
66
|
+
|
|
67
|
+
**`-rel->` and `<rel-` are two spellings of one relation.** `[A] -owns-> [B]` and `[A] <owns- [B]` both read
|
|
68
|
+
`owns(A, B)`; `<owns>` reads it both ways. A lens that writes the chain back writes each relation the way it was read.
|
|
69
|
+
|
|
70
|
+
**Backbone rule:** every connector but a relation advances the chain's backbone; a relation hangs its operand as a
|
|
71
|
+
*leaf* off the current backbone node. So `[A] -owns-> [B] ⇔ [C]` reads `owns(A, B)` **and** `equiv(A, C)` — `B` does not
|
|
72
|
+
become the left operand of the `⇔`.
|
|
73
|
+
|
|
74
|
+
A bounded term sitting between two backbone links is inferred as a **mediator**: `[A] ⇔ |🍯| ⇔ [B]` reads
|
|
75
|
+
`mediates(🍯, A, B)` — `honeyc norm` prints `🍯 mediates A target=B`. A relation an author names `mediates`
|
|
76
|
+
(`[A] -mediates-> [B]`) is an ordinary relation, `A mediates B`. `honeyc check` warns of an unbounded term in a
|
|
77
|
+
mediator's place and suggests the whole chain as it was written with that term bounded: `[A] ⇔ 🍯 ⇔ [B]` →
|
|
78
|
+
`[A] ⇔ |🍯| ⇔ [B]`, which parses back to the chain's statements plus `🍯 bounded true` and the mediation.
|
|
79
|
+
|
|
80
|
+
## Core example
|
|
81
|
+
|
|
82
|
+
```txt
|
|
83
|
+
[Mbr]: M*Boundary <is_a-🌸💧 ⇔ |🍯| ⇔ [Mbl]: M*Blanket <is_a-🍹
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`honeyc render --as triples` (the statements, each term's own marker left out) →
|
|
87
|
+
|
|
88
|
+
```txt
|
|
89
|
+
Mbr has_type M*Boundary
|
|
90
|
+
🍯 bounded true
|
|
91
|
+
Mbl has_type M*Blanket
|
|
92
|
+
Mbr is_a 🌸💧
|
|
93
|
+
Mbr equiv 🍯
|
|
94
|
+
🍯 equiv Mbr
|
|
95
|
+
🍯 equiv Mbl
|
|
96
|
+
Mbl equiv 🍯
|
|
97
|
+
Mbl is_a 🍹
|
|
98
|
+
🍯 mediates Mbr target=Mbl
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## The lenses
|
|
102
|
+
|
|
103
|
+
`readable` writes the chain back — each relation in the spelling it was read, each known glyph with its name beside
|
|
104
|
+
it (`[🌸💧: Nectar]`), so it is for reading; `triples`, `prose`, Prolog (`honeyc prolog`) and Cypher (`honeyc cypher`) are
|
|
105
|
+
read from the statements.
|
|
106
|
+
|
|
107
|
+
## Pipeline
|
|
108
|
+
|
|
109
|
+
```txt
|
|
110
|
+
parser.py RuneText ⇢ Lark tree ⇢ AST dataclasses (ast_nodes.py)
|
|
111
|
+
normalize.py AST ⇢ flat Statement triples
|
|
112
|
+
emit_prolog.py / emit_cypher.py triples ⇢ facts / graph
|
|
113
|
+
render.py AST/triples ⇢ readable | triples | prose
|
|
114
|
+
check.py AST ⇢ warnings + suggested rewrites
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Extending
|
|
118
|
+
|
|
119
|
+
- **New connector/glyph:** add a terminal to `grammar.lark`, a handler in
|
|
120
|
+
`parser.py`, a predicate in `normalize._CONNECTOR_PREDICATE`, and a label in
|
|
121
|
+
the emitters / `symbols.py`.
|
|
122
|
+
- **New renderer lens:** add a mode to `render.render`.
|
|
123
|
+
- **New domain compiler:** consume `honeyc.normalize` output and add your own
|
|
124
|
+
ontology/engine on top (this is how DietC is built).
|
|
125
|
+
|
|
126
|
+
## MVP limitations
|
|
127
|
+
|
|
128
|
+
- Cypher is emitted as text (no live Neo4j driver); Prolog is emitted as text
|
|
129
|
+
(no embedded SWI-Prolog).
|
|
130
|
+
- The third bundled example (`transform_chain.rune`) parses but exercises only
|
|
131
|
+
partial transform-object semantics.
|
|
132
|
+
- Unicode tokenization is permissive by design — validation happens in `check`.
|
|
133
|
+
|
|
134
|
+
## Tests
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pytest # the parser, normalize, the emitters, the lenses, check, this README's notation table, and dietc
|
|
138
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "honeyc"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A compiler and CLI for Dense Rune-Chain Notation"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = ["lark>=1.1", "click>=8.0", "pyyaml>=6.0"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
honeyc = "honeyc.cli:main"
|
|
14
|
+
dietc = "dietc.cli:main"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=7"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
honeyc = ["grammar.lark"]
|
honeyc-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""DietC command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .engine import compile_day
|
|
9
|
+
from .load import load_modules, load_profile
|
|
10
|
+
from .render import render_prose
|
|
11
|
+
from .rune import render_rune, render_rune_block, render_via_honeyc
|
|
12
|
+
|
|
13
|
+
_day_arg = click.argument("day", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
14
|
+
_profile_opt = click.option("--profile", required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
15
|
+
_modules_opt = click.option("--modules", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.version_option(message="dietc %(version)s")
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""DietC — modular nutrition compiler on HoneyC. Not medical advice."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.command()
|
|
25
|
+
@_day_arg
|
|
26
|
+
@_profile_opt
|
|
27
|
+
@_modules_opt
|
|
28
|
+
def compile(day: Path, profile: Path, modules: Path | None) -> None:
|
|
29
|
+
"""Compile a day: totals, gaps, caps, and recommended patches."""
|
|
30
|
+
state = compile_day(day, load_profile(profile), load_modules(modules) if modules else None)
|
|
31
|
+
click.echo(f"Day summary: {state.label}")
|
|
32
|
+
click.echo(f" gaps: {', '.join(state.gaps) or 'none'}")
|
|
33
|
+
click.echo(f" caps: {', '.join(state.caps) or 'none'}")
|
|
34
|
+
click.echo(f" matrix: {', '.join(state.matrix_gaps) or 'ok'}")
|
|
35
|
+
click.echo(f" patches: {', '.join(r.name for r in state.patches) or 'none'}")
|
|
36
|
+
for w in state.warnings:
|
|
37
|
+
click.echo(f" ! {w}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@main.command()
|
|
41
|
+
@_day_arg
|
|
42
|
+
@_profile_opt
|
|
43
|
+
def summary(day: Path, profile: Path) -> None:
|
|
44
|
+
"""Prose summary of the day (no patch modules required)."""
|
|
45
|
+
state = compile_day(day, load_profile(profile))
|
|
46
|
+
click.echo(render_prose(state))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@main.command()
|
|
50
|
+
@_day_arg
|
|
51
|
+
@_profile_opt
|
|
52
|
+
@_modules_opt
|
|
53
|
+
@click.option("--honeyc/--no-honeyc", default=False, help="Pass the rune block through HoneyC.")
|
|
54
|
+
def rune(day: Path, profile: Path, modules: Path | None, honeyc: bool) -> None:
|
|
55
|
+
"""Render the day as Dense Rune-Chain notation."""
|
|
56
|
+
state = compile_day(day, load_profile(profile), load_modules(modules) if modules else None)
|
|
57
|
+
click.echo(render_rune(state))
|
|
58
|
+
if honeyc:
|
|
59
|
+
out = render_via_honeyc(state)
|
|
60
|
+
click.echo("\n# via HoneyC:\n" + out if out else "\n(HoneyC not available)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@main.command()
|
|
64
|
+
@_day_arg
|
|
65
|
+
@_profile_opt
|
|
66
|
+
@_modules_opt
|
|
67
|
+
def check(day: Path, profile: Path, modules: Path | None) -> None:
|
|
68
|
+
"""Show prose + the recommended patches with their reasons."""
|
|
69
|
+
state = compile_day(day, load_profile(profile), load_modules(modules) if modules else None)
|
|
70
|
+
click.echo(render_prose(state))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Orchestrate the DietC daily compile loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import gapcap, patch, safety
|
|
7
|
+
from .load import load_day, load_items, load_modules, load_profile
|
|
8
|
+
from .models import DayState, GoalProfile, IntakeEvent, IntakeItem, PatchModule
|
|
9
|
+
from .vectors import MatrixVector, NutrientVector, SafetyVector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_day_state(events: list[IntakeEvent], items: dict[str, IntakeItem], label: str = "Day") -> DayState:
|
|
13
|
+
nutrient = NutrientVector()
|
|
14
|
+
matrix = MatrixVector()
|
|
15
|
+
safety_state = SafetyVector()
|
|
16
|
+
for ev in events:
|
|
17
|
+
item = items.get(ev.item_id)
|
|
18
|
+
if item is None:
|
|
19
|
+
safety_state.notes.append(f"unknown item: {ev.item_id}")
|
|
20
|
+
continue
|
|
21
|
+
nutrient = nutrient + item.nutrient_vector.scale(ev.amount)
|
|
22
|
+
matrix = matrix + item.matrix_vector.scale(ev.amount)
|
|
23
|
+
return DayState(events=events, nutrient_totals=nutrient, matrix_totals=matrix,
|
|
24
|
+
safety_state=safety_state, label=label)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compile_day(
|
|
28
|
+
day_path: Path | str,
|
|
29
|
+
profile: GoalProfile,
|
|
30
|
+
modules: dict[str, PatchModule] | None = None,
|
|
31
|
+
*,
|
|
32
|
+
items_override: dict[str, IntakeItem] | None = None,
|
|
33
|
+
) -> DayState:
|
|
34
|
+
events, items_file, label = load_day(day_path)
|
|
35
|
+
if items_override is not None:
|
|
36
|
+
items = items_override
|
|
37
|
+
else:
|
|
38
|
+
base = Path(day_path).parent
|
|
39
|
+
items = load_items(base / items_file) if items_file else {}
|
|
40
|
+
state = build_day_state(events, items, label=label)
|
|
41
|
+
|
|
42
|
+
gaps, caps, matrix_gaps, warnings = gapcap.compute(state.nutrient_totals, state.matrix_totals, profile)
|
|
43
|
+
state.gaps, state.caps, state.matrix_gaps, state.warnings = gaps, caps, matrix_gaps, warnings
|
|
44
|
+
|
|
45
|
+
if modules:
|
|
46
|
+
state.patches = patch.plan(gaps, caps, matrix_gaps, modules, profile)
|
|
47
|
+
state.warnings += safety.check(state.patches, modules, caps, profile)
|
|
48
|
+
return state
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Compute nutrient gaps, upper-limit caps, matrix gaps, and pattern warnings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .models import GoalProfile
|
|
5
|
+
from .vectors import MatrixVector, NutrientVector, positive_cap, positive_gap
|
|
6
|
+
|
|
7
|
+
_NEAR_LIMIT_FRACTION = 0.8
|
|
8
|
+
_CAP_WATCH_KEYS = ["added_sugar_g", "sodium_mg", "saturated_fat_g"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute(
|
|
12
|
+
nutrient_totals: NutrientVector,
|
|
13
|
+
matrix_totals: MatrixVector,
|
|
14
|
+
profile: GoalProfile,
|
|
15
|
+
) -> tuple[dict[str, float], dict[str, float], dict[str, float], list[str]]:
|
|
16
|
+
current = nutrient_totals.to_dict()
|
|
17
|
+
targets = {k: v for k, v in profile.targets.to_dict().items() if v > 0}
|
|
18
|
+
limits = {k: v for k, v in profile.upper_limits.to_dict().items() if v > 0}
|
|
19
|
+
min_matrix = {k: v for k, v in profile.minimum_matrix.to_dict().items() if v > 0}
|
|
20
|
+
|
|
21
|
+
gaps = positive_gap(targets, current)
|
|
22
|
+
caps = positive_cap(current, limits)
|
|
23
|
+
matrix_gaps = positive_gap(min_matrix, matrix_totals.to_dict())
|
|
24
|
+
|
|
25
|
+
warnings: list[str] = []
|
|
26
|
+
# near (but not over) upper limits
|
|
27
|
+
for key in _CAP_WATCH_KEYS:
|
|
28
|
+
if key in limits and key not in caps:
|
|
29
|
+
if current.get(key, 0) >= _NEAR_LIMIT_FRACTION * limits[key]:
|
|
30
|
+
warnings.append(f"{key} is approaching its upper limit "
|
|
31
|
+
f"({current.get(key, 0):.0f}/{limits[key]:.0f}).")
|
|
32
|
+
# over limit
|
|
33
|
+
for key, over in caps.items():
|
|
34
|
+
warnings.append(f"{key} exceeds its upper limit by {over:.0f}.")
|
|
35
|
+
# technically-patchable-but-poor-pattern signal
|
|
36
|
+
micros_ok = not any(k in gaps for k in ("calcium_mg", "magnesium_mg", "potassium_mg"))
|
|
37
|
+
if matrix_gaps and micros_ok and matrix_totals.whole_food_score < 2:
|
|
38
|
+
warnings.append("matrix/diversity is low even though some micronutrients are met; "
|
|
39
|
+
"pattern quality may be poor despite patching.")
|
|
40
|
+
return gaps, caps, matrix_gaps, warnings
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Load DietC items, profiles, patch modules, and day logs from YAML/JSON."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .models import GoalProfile, IntakeEvent, IntakeItem, PatchModule
|
|
9
|
+
from .vectors import MatrixVector, NutrientVector, SafetyVector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read(path: Path | str) -> dict:
|
|
13
|
+
return yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_items(path: Path | str) -> dict[str, IntakeItem]:
|
|
17
|
+
data = _read(path)
|
|
18
|
+
items: dict[str, IntakeItem] = {}
|
|
19
|
+
for raw in data.get("items", []):
|
|
20
|
+
item = IntakeItem(
|
|
21
|
+
id=raw["id"],
|
|
22
|
+
name=raw.get("name", raw["id"]),
|
|
23
|
+
category=raw.get("category", "unknown"),
|
|
24
|
+
serving_size=float(raw.get("serving_size", 1)),
|
|
25
|
+
serving_unit=raw.get("serving_unit", "serving"),
|
|
26
|
+
nutrient_vector=NutrientVector.from_dict(raw.get("nutrient_vector")),
|
|
27
|
+
matrix_vector=MatrixVector.from_dict(raw.get("matrix_vector")),
|
|
28
|
+
safety_vector=SafetyVector.from_dict(raw.get("safety_vector")),
|
|
29
|
+
tags=list(raw.get("tags", [])),
|
|
30
|
+
confidence=float(raw.get("confidence", 1.0)),
|
|
31
|
+
)
|
|
32
|
+
items[item.id] = item
|
|
33
|
+
return items
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_profile(path: Path | str) -> GoalProfile:
|
|
37
|
+
data = _read(path)
|
|
38
|
+
return GoalProfile(
|
|
39
|
+
id=data.get("id", "profile"),
|
|
40
|
+
name=data.get("name", "Profile"),
|
|
41
|
+
targets=NutrientVector.from_dict(data.get("targets")),
|
|
42
|
+
upper_limits=NutrientVector.from_dict(data.get("upper_limits")),
|
|
43
|
+
minimum_matrix=MatrixVector.from_dict(data.get("minimum_matrix")),
|
|
44
|
+
preferences=dict(data.get("preferences", {})),
|
|
45
|
+
constraints=dict(data.get("constraints", {})),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_modules(path: Path | str) -> dict[str, PatchModule]:
|
|
50
|
+
data = _read(path)
|
|
51
|
+
modules: dict[str, PatchModule] = {}
|
|
52
|
+
for raw in data.get("modules", []):
|
|
53
|
+
module = PatchModule(
|
|
54
|
+
id=raw["id"],
|
|
55
|
+
name=raw.get("name", raw["id"]),
|
|
56
|
+
category=raw.get("category", "unknown"),
|
|
57
|
+
contributes=NutrientVector.from_dict(raw.get("contributes")),
|
|
58
|
+
matrix=MatrixVector.from_dict(raw.get("matrix")),
|
|
59
|
+
avoids=list(raw.get("avoids", [])),
|
|
60
|
+
dose_min=float(raw.get("dose_min", 0)),
|
|
61
|
+
dose_max=float(raw.get("dose_max", 1)),
|
|
62
|
+
dose_step=float(raw.get("dose_step", 1)),
|
|
63
|
+
unit=raw.get("unit", "serving"),
|
|
64
|
+
tags=list(raw.get("tags", [])),
|
|
65
|
+
warnings=list(raw.get("warnings", [])),
|
|
66
|
+
cost_score=float(raw.get("cost_score", 0)),
|
|
67
|
+
adherence_score=float(raw.get("adherence_score", 0)),
|
|
68
|
+
)
|
|
69
|
+
modules[module.id] = module
|
|
70
|
+
return modules
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_day(path: Path | str) -> tuple[list[IntakeEvent], str | None, str]:
|
|
74
|
+
"""Return (events, items_file (relative), label)."""
|
|
75
|
+
data = _read(path)
|
|
76
|
+
events = [
|
|
77
|
+
IntakeEvent(
|
|
78
|
+
item_id=e["item_id"],
|
|
79
|
+
amount=float(e.get("amount", 1)),
|
|
80
|
+
unit=e.get("unit", "serving"),
|
|
81
|
+
timestamp=e.get("timestamp"),
|
|
82
|
+
notes=e.get("notes"),
|
|
83
|
+
)
|
|
84
|
+
for e in data.get("events", [])
|
|
85
|
+
]
|
|
86
|
+
label = data.get("label") or Path(path).stem.replace("_", " ").title().replace(" ", "")
|
|
87
|
+
return events, data.get("items_file"), label
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Container models for DietC."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .vectors import MatrixVector, NutrientVector, SafetyVector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IntakeItem:
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
category: str
|
|
15
|
+
nutrient_vector: NutrientVector
|
|
16
|
+
matrix_vector: MatrixVector
|
|
17
|
+
safety_vector: SafetyVector
|
|
18
|
+
serving_size: float = 1
|
|
19
|
+
serving_unit: str = "serving"
|
|
20
|
+
tags: list[str] = field(default_factory=list)
|
|
21
|
+
confidence: float = 1.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class IntakeEvent:
|
|
26
|
+
item_id: str
|
|
27
|
+
amount: float = 1
|
|
28
|
+
unit: str = "serving"
|
|
29
|
+
timestamp: str | None = None
|
|
30
|
+
notes: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class GoalProfile:
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
targets: NutrientVector
|
|
38
|
+
upper_limits: NutrientVector
|
|
39
|
+
minimum_matrix: MatrixVector
|
|
40
|
+
preferences: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PatchModule:
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
category: str
|
|
49
|
+
contributes: NutrientVector
|
|
50
|
+
matrix: MatrixVector
|
|
51
|
+
avoids: list[str] = field(default_factory=list)
|
|
52
|
+
dose_min: float = 0
|
|
53
|
+
dose_max: float = 1
|
|
54
|
+
dose_step: float = 1
|
|
55
|
+
unit: str = "serving"
|
|
56
|
+
tags: list[str] = field(default_factory=list)
|
|
57
|
+
warnings: list[str] = field(default_factory=list)
|
|
58
|
+
cost_score: float = 0
|
|
59
|
+
adherence_score: float = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PatchRecommendation:
|
|
64
|
+
module_id: str
|
|
65
|
+
name: str
|
|
66
|
+
dose: float
|
|
67
|
+
unit: str
|
|
68
|
+
reasons: list[str] = field(default_factory=list)
|
|
69
|
+
warnings: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class DayState:
|
|
74
|
+
events: list[IntakeEvent]
|
|
75
|
+
nutrient_totals: NutrientVector
|
|
76
|
+
matrix_totals: MatrixVector
|
|
77
|
+
safety_state: SafetyVector
|
|
78
|
+
gaps: dict[str, float] = field(default_factory=dict)
|
|
79
|
+
caps: dict[str, float] = field(default_factory=dict)
|
|
80
|
+
matrix_gaps: dict[str, float] = field(default_factory=dict)
|
|
81
|
+
warnings: list[str] = field(default_factory=list)
|
|
82
|
+
patches: list[PatchRecommendation] = field(default_factory=list)
|
|
83
|
+
label: str = "Day"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Greedy patch planner: pick modules that close gaps without violating caps."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from .models import GoalProfile, PatchModule, PatchRecommendation
|
|
7
|
+
|
|
8
|
+
_NUTRIENT_LABEL = {
|
|
9
|
+
"protein_g": "protein", "fiber_g": "fiber", "omega3_g": "omega-3",
|
|
10
|
+
"potassium_mg": "potassium", "magnesium_mg": "magnesium", "calcium_mg": "calcium",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan(
|
|
15
|
+
gaps: dict[str, float],
|
|
16
|
+
caps: dict[str, float],
|
|
17
|
+
matrix_gaps: dict[str, float],
|
|
18
|
+
modules: dict[str, PatchModule],
|
|
19
|
+
profile: GoalProfile,
|
|
20
|
+
*,
|
|
21
|
+
max_modules: int = 6,
|
|
22
|
+
) -> list[PatchRecommendation]:
|
|
23
|
+
remaining_n = dict(gaps)
|
|
24
|
+
remaining_m = dict(matrix_gaps)
|
|
25
|
+
prefs = profile.preferences
|
|
26
|
+
chosen: list[PatchRecommendation] = []
|
|
27
|
+
available = dict(modules)
|
|
28
|
+
|
|
29
|
+
while available and (remaining_n or remaining_m) and len(chosen) < max_modules:
|
|
30
|
+
best_id, best_score, best = None, 0.0, None
|
|
31
|
+
for mid, m in available.items():
|
|
32
|
+
score = _score(m, remaining_n, remaining_m, caps, prefs)
|
|
33
|
+
if score > best_score:
|
|
34
|
+
best_id, best_score, best = mid, score, m
|
|
35
|
+
if best is None:
|
|
36
|
+
break
|
|
37
|
+
available.pop(best_id)
|
|
38
|
+
rec = _recommend(best, remaining_n, remaining_m, prefs)
|
|
39
|
+
chosen.append(rec)
|
|
40
|
+
# subtract contribution at the chosen dose
|
|
41
|
+
contributes = best.contributes.scale(rec.dose).to_dict()
|
|
42
|
+
matrix = best.matrix.scale(rec.dose).to_dict()
|
|
43
|
+
for k, v in contributes.items():
|
|
44
|
+
if k in remaining_n:
|
|
45
|
+
remaining_n[k] = max(0.0, remaining_n[k] - v)
|
|
46
|
+
if remaining_n[k] <= 1e-9:
|
|
47
|
+
remaining_n.pop(k)
|
|
48
|
+
for k, v in matrix.items():
|
|
49
|
+
if k in remaining_m:
|
|
50
|
+
remaining_m[k] = max(0.0, remaining_m[k] - v)
|
|
51
|
+
if remaining_m[k] <= 1e-9:
|
|
52
|
+
remaining_m.pop(k)
|
|
53
|
+
return chosen
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _adds_to_capped(module: PatchModule, caps: dict[str, float]) -> bool:
|
|
57
|
+
contributes = module.contributes.to_dict()
|
|
58
|
+
return any(contributes.get(k, 0) > 0 for k in caps)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _score(module: PatchModule, gaps, matrix_gaps, caps, prefs) -> float:
|
|
62
|
+
contributes = module.contributes.to_dict()
|
|
63
|
+
matrix = module.matrix.to_dict()
|
|
64
|
+
gap_cover = sum(1 for k in gaps if contributes.get(k, 0) > 0)
|
|
65
|
+
matrix_cover = sum(1 for k in matrix_gaps if matrix.get(k, 0) > 0)
|
|
66
|
+
score = 2.0 * gap_cover + 1.5 * matrix_cover + module.adherence_score - module.cost_score
|
|
67
|
+
if _adds_to_capped(module, caps):
|
|
68
|
+
score -= 3.0
|
|
69
|
+
if prefs.get("avoid_high_sodium_patches") and contributes.get("sodium_mg", 0) > 50:
|
|
70
|
+
score -= 2.0
|
|
71
|
+
if prefs.get("prefer_drinks") and module.category == "drink":
|
|
72
|
+
score += 0.5
|
|
73
|
+
if prefs.get("prefer_low_prep") and module.category in ("drink", "capsule", "bar", "powder"):
|
|
74
|
+
score += 0.3
|
|
75
|
+
return score
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _recommend(module: PatchModule, gaps, matrix_gaps, prefs) -> PatchRecommendation:
|
|
79
|
+
contributes = module.contributes.to_dict()
|
|
80
|
+
matrix = module.matrix.to_dict()
|
|
81
|
+
addressed_n = [k for k in gaps if contributes.get(k, 0) > 0]
|
|
82
|
+
addressed_m = [k for k in matrix_gaps if matrix.get(k, 0) > 0]
|
|
83
|
+
|
|
84
|
+
# meter dose to the largest nutrient gap it covers, clamped to the module's range
|
|
85
|
+
dose = module.dose_step or 1
|
|
86
|
+
if addressed_n:
|
|
87
|
+
needs = max(gaps[k] / contributes[k] for k in addressed_n)
|
|
88
|
+
steps = max(1, math.ceil(needs / (module.dose_step or 1)))
|
|
89
|
+
dose = min(module.dose_max, steps * (module.dose_step or 1))
|
|
90
|
+
dose = max(dose, module.dose_min or 0) or (module.dose_step or 1)
|
|
91
|
+
|
|
92
|
+
reasons: list[str] = []
|
|
93
|
+
if addressed_n:
|
|
94
|
+
names = ", ".join(_NUTRIENT_LABEL.get(k, k) for k in addressed_n)
|
|
95
|
+
reasons.append(f"improves {names} (below target)")
|
|
96
|
+
if addressed_m:
|
|
97
|
+
reasons.append("improves plant/color/polyphenol matrix coverage")
|
|
98
|
+
if prefs.get("avoid_high_sodium_patches") and contributes.get("sodium_mg", 0) <= 50 and "potassium_mg" in addressed_n:
|
|
99
|
+
reasons.append("adds potassium without adding sodium")
|
|
100
|
+
if not reasons:
|
|
101
|
+
reasons.append("supports overall coverage")
|
|
102
|
+
return PatchRecommendation(
|
|
103
|
+
module_id=module.id,
|
|
104
|
+
name=module.name,
|
|
105
|
+
dose=dose,
|
|
106
|
+
unit=module.unit,
|
|
107
|
+
reasons=reasons,
|
|
108
|
+
warnings=list(module.warnings),
|
|
109
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Prose rendering of a DayState."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .models import DayState
|
|
5
|
+
|
|
6
|
+
_GAP_WORD = {
|
|
7
|
+
"protein_g": "protein", "fiber_g": "fiber", "omega3_g": "omega-3",
|
|
8
|
+
"potassium_mg": "potassium", "magnesium_mg": "magnesium", "calcium_mg": "calcium",
|
|
9
|
+
"calories": "calories",
|
|
10
|
+
}
|
|
11
|
+
_MATRIX_WORD = {
|
|
12
|
+
"plant_diversity_score": "plant diversity", "color_diversity_score": "color diversity",
|
|
13
|
+
"berry_count": "berries", "green_leafy_count": "leafy greens", "polyphenol_score": "polyphenols",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_prose(state: DayState) -> str:
|
|
18
|
+
# opening inline sentences
|
|
19
|
+
sentences: list[str] = []
|
|
20
|
+
items = ", ".join(dict.fromkeys(e.item_id.replace("_", " ") for e in state.events))
|
|
21
|
+
sentences.append(f"This day ({state.label}) is built from {items}.")
|
|
22
|
+
|
|
23
|
+
short = [_GAP_WORD.get(k, k) for k in state.gaps] + [_MATRIX_WORD.get(k, k) for k in state.matrix_gaps]
|
|
24
|
+
if short:
|
|
25
|
+
sentences.append("It remains low in " + _join(short) + ".")
|
|
26
|
+
if state.caps:
|
|
27
|
+
sentences.append("It exceeds limits on " + _join(list(state.caps)) + ".")
|
|
28
|
+
|
|
29
|
+
blocks: list[str] = [" ".join(sentences)]
|
|
30
|
+
|
|
31
|
+
if state.patches:
|
|
32
|
+
lines = ["Recommended patches:"]
|
|
33
|
+
for i, r in enumerate(state.patches, 1):
|
|
34
|
+
warn = (" " + " ".join(r.warnings)) if r.warnings else ""
|
|
35
|
+
lines.append(f"{i}. {r.name} ({r.dose:g} {r.unit}): {'; '.join(r.reasons)}.{warn}")
|
|
36
|
+
blocks.append("\n".join(lines))
|
|
37
|
+
|
|
38
|
+
if state.warnings:
|
|
39
|
+
blocks.append("Warnings:\n" + "\n".join(f"- {w}" for w in state.warnings))
|
|
40
|
+
|
|
41
|
+
return "\n\n".join(blocks)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _join(words: list[str]) -> str:
|
|
45
|
+
words = list(dict.fromkeys(words))
|
|
46
|
+
if len(words) == 1:
|
|
47
|
+
return words[0]
|
|
48
|
+
return ", ".join(words[:-1]) + f", and {words[-1]}"
|