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.
Files changed (38) hide show
  1. honeyc-0.3.0/PKG-INFO +10 -0
  2. honeyc-0.3.0/README.md +138 -0
  3. honeyc-0.3.0/pyproject.toml +23 -0
  4. honeyc-0.3.0/setup.cfg +4 -0
  5. honeyc-0.3.0/src/dietc/__init__.py +4 -0
  6. honeyc-0.3.0/src/dietc/cli.py +74 -0
  7. honeyc-0.3.0/src/dietc/engine.py +48 -0
  8. honeyc-0.3.0/src/dietc/gapcap.py +40 -0
  9. honeyc-0.3.0/src/dietc/load.py +87 -0
  10. honeyc-0.3.0/src/dietc/models.py +83 -0
  11. honeyc-0.3.0/src/dietc/patch.py +109 -0
  12. honeyc-0.3.0/src/dietc/render.py +48 -0
  13. honeyc-0.3.0/src/dietc/rune.py +83 -0
  14. honeyc-0.3.0/src/dietc/safety.py +52 -0
  15. honeyc-0.3.0/src/dietc/vectors.py +137 -0
  16. honeyc-0.3.0/src/honeyc/__init__.py +9 -0
  17. honeyc-0.3.0/src/honeyc/ast_nodes.py +73 -0
  18. honeyc-0.3.0/src/honeyc/check.py +89 -0
  19. honeyc-0.3.0/src/honeyc/cli.py +79 -0
  20. honeyc-0.3.0/src/honeyc/emit_cypher.py +82 -0
  21. honeyc-0.3.0/src/honeyc/emit_prolog.py +51 -0
  22. honeyc-0.3.0/src/honeyc/grammar.lark +75 -0
  23. honeyc-0.3.0/src/honeyc/normalize.py +173 -0
  24. honeyc-0.3.0/src/honeyc/parser.py +144 -0
  25. honeyc-0.3.0/src/honeyc/render.py +179 -0
  26. honeyc-0.3.0/src/honeyc/symbols.py +49 -0
  27. honeyc-0.3.0/src/honeyc.egg-info/PKG-INFO +10 -0
  28. honeyc-0.3.0/src/honeyc.egg-info/SOURCES.txt +36 -0
  29. honeyc-0.3.0/src/honeyc.egg-info/dependency_links.txt +1 -0
  30. honeyc-0.3.0/src/honeyc.egg-info/entry_points.txt +3 -0
  31. honeyc-0.3.0/src/honeyc.egg-info/requires.txt +6 -0
  32. honeyc-0.3.0/src/honeyc.egg-info/top_level.txt +2 -0
  33. honeyc-0.3.0/tests/test_emitters.py +35 -0
  34. honeyc-0.3.0/tests/test_honeyc_regressions.py +118 -0
  35. honeyc-0.3.0/tests/test_normalize.py +49 -0
  36. honeyc-0.3.0/tests/test_parser.py +63 -0
  37. honeyc-0.3.0/tests/test_readme_notation.py +121 -0
  38. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """DietC — a modular nutrition compiler built on HoneyC. NOT medical advice."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.3.0"
@@ -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]}"