beefcot 1.0.3__tar.gz → 1.0.4__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 (123) hide show
  1. {beefcot-1.0.3 → beefcot-1.0.4}/.gitignore +3 -0
  2. {beefcot-1.0.3 → beefcot-1.0.4}/PKG-INFO +13 -1
  3. {beefcot-1.0.3 → beefcot-1.0.4}/README.pypi.md +8 -0
  4. {beefcot-1.0.3 → beefcot-1.0.4}/pyproject.toml +4 -0
  5. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/__init__.py +2 -2
  6. beefcot-1.0.4/src/beefcot/__main__.py +5 -0
  7. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cli.py +137 -3
  8. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cli_presentation.py +30 -1
  9. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cutting_plan/economics.py +68 -0
  10. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cutting_plan/run.py +13 -1
  11. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/learners.py +10 -1
  12. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/transforms.py +14 -0
  13. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/blocks.py +12 -2
  14. beefcot-1.0.4/src/beefcot/project.py +202 -0
  15. beefcot-1.0.4/src/beefcot/project_registry.py +150 -0
  16. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/registry.py +1 -1
  17. beefcot-1.0.4/src/beefcot/regression/assembly.py +397 -0
  18. beefcot-1.0.4/src/beefcot/regression/assembly_network.py +61 -0
  19. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/exhaustive.py +146 -37
  20. beefcot-1.0.4/src/beefcot/regression/exhaustive_parallel.py +110 -0
  21. beefcot-1.0.4/src/beefcot/regression/exhaustive_storage.py +182 -0
  22. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/run.py +6 -1
  23. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/spec.py +35 -6
  24. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/run.py +46 -5
  25. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/schemas/model-3.schema.json +3 -1
  26. beefcot-1.0.4/src/beefcot/service/__init__.py +5 -0
  27. beefcot-1.0.4/src/beefcot/service/app.py +512 -0
  28. beefcot-1.0.4/src/beefcot/service/auth.py +219 -0
  29. beefcot-1.0.4/src/beefcot/service/errors.py +11 -0
  30. beefcot-1.0.4/src/beefcot/service/plans.py +129 -0
  31. beefcot-1.0.4/src/beefcot/service/projects.py +63 -0
  32. beefcot-1.0.4/src/beefcot/service/runner.py +109 -0
  33. beefcot-1.0.4/src/beefcot/service/server.py +23 -0
  34. beefcot-1.0.4/src/beefcot/service/studies.py +206 -0
  35. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/anomaly.py +0 -0
  36. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/chain.py +0 -0
  37. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/__init__.py +0 -0
  38. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/anomaly.py +0 -0
  39. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/anomaly_figures.py +0 -0
  40. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/bins.py +0 -0
  41. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/cut_tree.py +0 -0
  42. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/figures.py +0 -0
  43. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/forest.py +0 -0
  44. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/report.py +0 -0
  45. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/review.py +0 -0
  46. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/run.py +0 -0
  47. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/spec.py +0 -0
  48. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/checks/table.py +0 -0
  49. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cutting_plan/__init__.py +0 -0
  50. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/cutting_plan/solver.py +0 -0
  51. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/__init__.py +0 -0
  52. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/analysis.py +0 -0
  53. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/fits.py +0 -0
  54. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/report.py +0 -0
  55. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/run.py +0 -0
  56. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/sources.py +0 -0
  57. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/decomposition/spec.py +0 -0
  58. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/__init__.py +0 -0
  59. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/analysis.py +0 -0
  60. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/methods.py +0 -0
  61. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/plots.py +0 -0
  62. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/report.py +0 -0
  63. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/run.py +0 -0
  64. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/sources.py +0 -0
  65. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/diagnostics/spec.py +0 -0
  66. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/__init__.py +0 -0
  67. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/expression.py +0 -0
  68. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/functional.py +0 -0
  69. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/learner_registry.py +0 -0
  70. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/rows.py +0 -0
  71. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/runtime.py +0 -0
  72. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/topology.py +0 -0
  73. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model/types.py +0 -0
  74. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/__init__.py +0 -0
  75. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/card.py +0 -0
  76. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/documents.py +0 -0
  77. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/export.py +0 -0
  78. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/fitted.py +0 -0
  79. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/library.py +0 -0
  80. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/model_format/schema.py +0 -0
  81. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/prediction/__init__.py +0 -0
  82. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/prediction/run.py +0 -0
  83. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/producers/__init__.py +0 -0
  84. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/producers/network.py +0 -0
  85. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/producers/rf/__init__.py +0 -0
  86. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/producers/rf/loop.py +0 -0
  87. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/producers/rf/relevance.py +0 -0
  88. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/__init__.py +0 -0
  89. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/anomaly.py +0 -0
  90. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/candidates.py +0 -0
  91. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/factory.py +0 -0
  92. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/mapping_review.py +0 -0
  93. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/mapping_space.py +0 -0
  94. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/provenance.py +0 -0
  95. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf.py +0 -0
  96. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_checkpoint.py +0 -0
  97. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_controller.py +0 -0
  98. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_effort.py +0 -0
  99. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_equivalence.py +0 -0
  100. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_policy.py +0 -0
  101. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_selection.py +0 -0
  102. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_settings.py +0 -0
  103. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rerf_warm_start.py +0 -0
  104. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/residual.py +0 -0
  105. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/rf_expression.py +0 -0
  106. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/regression/table.py +0 -0
  107. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/__init__.py +0 -0
  108. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/metrics.py +0 -0
  109. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/plots.py +0 -0
  110. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/regression_space.py +0 -0
  111. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/reports/style.py +0 -0
  112. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/resources/cut_tree.default.json +0 -0
  113. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/resources/rerf_space.default.yaml +0 -0
  114. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/__init__.py +0 -0
  115. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/expression_ast.py +0 -0
  116. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/loader.py +0 -0
  117. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/model.py +0 -0
  118. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/schemas/checks-3.schema.json +0 -0
  119. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/schemas/predictors-3.schema.json +0 -0
  120. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/schemas/request-3.schema.json +0 -0
  121. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/runtime/schemas/result-3.schema.json +0 -0
  122. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/workspace.py +0 -0
  123. {beefcot-1.0.3 → beefcot-1.0.4}/src/beefcot/yamlio.py +0 -0
@@ -28,6 +28,9 @@ coverage.xml
28
28
  # Runtime caches
29
29
  .cache/
30
30
 
31
+ # Workbench passwords (scrypt hashes). Created by `beefcot user add`.
32
+ /config/auth/
33
+
31
34
  # Run records (written under the workspace's runs folder).
32
35
  /runs/
33
36
  /workflow-log/
@@ -1,8 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beefcot
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Summary: Carcass yield models and cutting plans: one YAML in, one delivered model out
5
5
  Requires-Python: >=3.13
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: httpx>=0.27
6
8
  Requires-Dist: jarvisplot>=2.1
7
9
  Requires-Dist: numpy>=2.5
8
10
  Requires-Dist: pandas>=3.0
@@ -10,6 +12,8 @@ Requires-Dist: pytest>=8
10
12
  Requires-Dist: pyyaml>=6.0
11
13
  Requires-Dist: scikit-learn>=1.9
12
14
  Requires-Dist: scipy>=1.18
15
+ Requires-Dist: threadpoolctl>=3.6
16
+ Requires-Dist: uvicorn>=0.32
13
17
  Requires-Dist: xgboost>=3.3
14
18
  Description-Content-Type: text/markdown
15
19
 
@@ -23,8 +27,16 @@ python3 -m pip install beefcot
23
27
  beefcot --help
24
28
  beefcot validate /path/to/job.yaml
25
29
  beefcot fit /path/to/job.yaml
30
+ beefcot project create HERD
31
+ beefcot project list
26
32
  ```
27
33
 
34
+ `beefcot project create` writes one project directory. The machine list
35
+ defaults to `~/.beefcot/projects.json`. Pass `--projects` or set
36
+ `BEEFCOT_PROJECTS` to use another file. `beefcot serve` reads that list and
37
+ opens a loopback workbench. The HTML pages stay with the source checkout and
38
+ are not part of this package.
39
+
28
40
  Each fit starts from a `kind: regression_tree` YAML declaring its input table,
29
41
  feature and target columns, cut-tree mapping, split and regression space.
30
42
  Available methods are profile, RF, DNN, ReRF and exhaustive search. Structural
@@ -8,8 +8,16 @@ python3 -m pip install beefcot
8
8
  beefcot --help
9
9
  beefcot validate /path/to/job.yaml
10
10
  beefcot fit /path/to/job.yaml
11
+ beefcot project create HERD
12
+ beefcot project list
11
13
  ```
12
14
 
15
+ `beefcot project create` writes one project directory. The machine list
16
+ defaults to `~/.beefcot/projects.json`. Pass `--projects` or set
17
+ `BEEFCOT_PROJECTS` to use another file. `beefcot serve` reads that list and
18
+ opens a loopback workbench. The HTML pages stay with the source checkout and
19
+ are not part of this package.
20
+
13
21
  Each fit starts from a `kind: regression_tree` YAML declaring its input table,
14
22
  feature and target columns, cut-tree mapping, split and regression space.
15
23
  Available methods are profile, RF, DNN, ReRF and exhaustive search. Structural
@@ -16,10 +16,14 @@ dependencies = [
16
16
  "pandas>=3.0",
17
17
  "scipy>=1.18",
18
18
  "scikit-learn>=1.9",
19
+ "threadpoolctl>=3.6",
19
20
  "xgboost>=3.3",
20
21
  "PyYAML>=6.0",
21
22
  "JarvisPLOT>=2.1",
22
23
  "pytest>=8",
24
+ "fastapi>=0.115",
25
+ "uvicorn>=0.32",
26
+ "httpx>=0.27",
23
27
  ]
24
28
 
25
29
  [project.scripts]
@@ -6,7 +6,7 @@ Every job is one YAML and one verb, the same in Python as on the command line (`
6
6
 
7
7
  beefcot.fit("config/regression/murdoch_rf.yaml") # kind: regression_tree -> a run with a model
8
8
  beefcot.predict("config/prediction/murdoch_rf_241.yaml") # kind: prediction -> one table
9
- beefcot.plan("config/plan/murdoch_demo.yaml") # kind: cutting_plan -> a plan
9
+ beefcot.plan("config/plan/breakdown.yaml") # kind: cutting_plan -> a plan
10
10
  beefcot.check("config/checks/murdoch_core3_bins.yaml") # kind: bin_variance -> a data check
11
11
  beefcot.decompose("config/decomposition/murdoch_rf.yaml") # kind: error_decomposition -> noise / bias² / variance
12
12
  beefcot.run("config/pipelines/murdoch_smoke.yaml") # kind: pipeline -> several, in order
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  from pathlib import Path
25
25
  from typing import Any
26
26
 
27
- __version__ = "1.0.3" # the one place it is written; CHANGELOG.md records each version
27
+ __version__ = "1.0.4" # the one place it is written; CHANGELOG.md records each version
28
28
 
29
29
  __all__ = ["__version__", "fit", "predict", "plan", "check", "decompose", "run", "load_model"]
30
30
 
@@ -0,0 +1,5 @@
1
+ """``python -m beefcot`` is the same command as the ``beefcot`` script."""
2
+
3
+ from beefcot.cli import main
4
+
5
+ raise SystemExit(main())
@@ -2,12 +2,19 @@
2
2
 
3
3
  beefcot fit config/regression/murdoch_rf.yaml # kind: regression_tree -> a run with a model
4
4
  beefcot predict config/prediction/murdoch_rf_241.yaml # kind: prediction -> one table
5
- beefcot plan config/plan/murdoch_demo.yaml # kind: cutting_plan -> a plan
5
+ beefcot plan config/plan/breakdown.yaml # kind: cutting_plan -> a plan
6
6
  beefcot check config/checks/murdoch_core3_bins.yaml # kind: bin_variance -> a data check
7
7
  beefcot decompose config/decomposition/murdoch_rf.yaml # kind: error_decomposition -> noise / bias² / variance
8
8
  beefcot run config/pipelines/murdoch_smoke.yaml # kind: pipeline -> several, in order
9
9
  beefcot validate <any YAML> # read and check it, compute nothing
10
10
  beefcot models list | show ID | verify ID | import PATH | export ID OUT
11
+ beefcot serve # loopback workbench; --design draws the column grid
12
+ beefcot user add NAME # create a workbench login
13
+ beefcot project create PATH # a standard project and an empty model library
14
+ beefcot project validate [PATH] # check that project; compute nothing
15
+ beefcot project list # projects recorded on this machine
16
+ beefcot project add PATH # record an existing project
17
+ beefcot project remove ID # drop it from the list; keep the directory
11
18
  beefcot kinds | version
12
19
  beefcot -V | -v | --version # package version, no job required
13
20
 
@@ -35,6 +42,33 @@ PRODUCING = ("fit", "predict", "plan", "check", "decompose", "run")
35
42
  WITH_LIBRARY = ("predict", "plan", "run") # the jobs that may look a model up by id
36
43
 
37
44
 
45
+ def _user(args: argparse.Namespace) -> int:
46
+ """Create or list workbench accounts. The password is read from the terminal."""
47
+ import getpass
48
+
49
+ from beefcot.service.auth import Accounts, AuthError
50
+
51
+ root = Path(args.root).expanduser() if args.root else Path.cwd()
52
+ accounts = Accounts(root / "config" / "auth" / "users.yaml")
53
+ try:
54
+ if args.user_command == "list":
55
+ for name in accounts.names():
56
+ print(name)
57
+ return 0
58
+ if not sys.stdin.isatty():
59
+ raise AuthError(422, "INVALID_PASSWORD", "run beefcot user add in a terminal so the password is not saved in a command")
60
+ password = getpass.getpass("Password: ")
61
+ repeated = getpass.getpass("Repeat password: ")
62
+ if password != repeated:
63
+ raise AuthError(422, "INVALID_PASSWORD", "the two passwords did not match")
64
+ accounts.add(args.name, password)
65
+ except AuthError as failure:
66
+ print(failure.message, file=sys.stderr)
67
+ return 1
68
+ print(f"added {args.name}")
69
+ return 0
70
+
71
+
38
72
  def _produce(args: argparse.Namespace) -> dict[str, Any]:
39
73
  import beefcot
40
74
  from beefcot import registry
@@ -135,6 +169,26 @@ def _models(args: argparse.Namespace) -> dict[str, Any]:
135
169
  return {"ok": True, "id": args.id, "written": str(written)}
136
170
 
137
171
 
172
+ def _project(args: argparse.Namespace) -> dict[str, Any]:
173
+ """Create or check a project, or change the machine list. None of these fit a model."""
174
+ from beefcot.project import create, validate
175
+ from beefcot.project_registry import add, describe, location, remove
176
+
177
+ registry = location(getattr(args, "projects", None))
178
+ if args.project_command == "create":
179
+ report = create(args.path)
180
+ report.update(add(registry, report["project"]))
181
+ report["registry"] = str(registry)
182
+ return report
183
+ if args.project_command == "add":
184
+ return {"ok": True, "registry": str(registry), **add(registry, args.path)}
185
+ if args.project_command == "remove":
186
+ return {"ok": True, "registry": str(registry), **remove(registry, args.id)}
187
+ if args.project_command == "list":
188
+ return {"ok": True, "registry": str(registry), "projects": describe(registry)}
189
+ return validate(args.path or Path.cwd())
190
+
191
+
138
192
  def _kinds() -> dict[str, Any]:
139
193
  from beefcot import registry
140
194
 
@@ -146,9 +200,10 @@ def _parser(presentation: Terminal | None = None) -> argparse.ArgumentParser:
146
200
  parser = HelpParser(prog="beefcot", presentation=presentation,
147
201
  description="Define a job in YAML. Validate it, run it, inspect the delivered results.",
148
202
  help_panels=[('Regression & data', ('validate', 'fit', 'predict', 'check', 'decompose')),
149
- ('Planning & pipelines', ('plan', 'run')),
203
+ ('Planning & pipelines', ('plan', 'run', 'serve', 'user', 'project')),
150
204
  ('Models & reference', ('models', 'kinds', 'version'))],
151
205
  examples=[('Validate', 'beefcot validate path/to/job.yaml'),
206
+ ('New project', 'beefcot project create HERD'),
152
207
  ('Fit', 'beefcot fit config/regression/murdoch_core3_rf.yaml'),
153
208
  ('Command help', 'beefcot fit --help')])
154
209
  parser.add_argument('-V', '-v', '--version', action=VersionAction,
@@ -186,6 +241,63 @@ def _parser(presentation: Terminal | None = None) -> argparse.ArgumentParser:
186
241
  command(name, helps[name], library=name in WITH_LIBRARY, quiet=True).add_argument("config", metavar="YAML", help="job configuration; paths inside it are relative to this YAML")
187
242
  command("validate", "validate a YAML and its paths without running the job", library=True).add_argument(
188
243
  "config", metavar="YAML", help="the configuration to validate")
244
+ serve = sub.add_parser("serve", help="serve the local cutting-plan workbench on 127.0.0.1",
245
+ description="Serve the local cutting-plan workbench. The host is loopback only.",
246
+ examples=[("Open", "beefcot serve"), ("Grid", "beefcot serve --design")])
247
+ presentation_options(serve)
248
+ serve.add_argument("--port", type=int, default=8765, help="loopback port (default: 8765)")
249
+ serve.add_argument("--root", default=None, help="project directory that contains config/ (default: the current directory)")
250
+ serve.add_argument("--design", action="store_true", help="draw the 12-column reference grid in magenta")
251
+ serve.add_argument("--projects", default=None, help="the machine project list (default: ~/.beefcot/projects.json)")
252
+
253
+ account = sub.add_parser("user", help="add or list workbench logins",
254
+ description="Add a workbench login. The password is typed, not passed as an argument. "
255
+ "The web page cannot register a user.",
256
+ examples=[("Add", "beefcot user add NAME"), ("List", "beefcot user list")])
257
+ presentation_options(account)
258
+ account_commands = account.add_subparsers(dest="user_command", required=True)
259
+ added = account_commands.add_parser("add", help="create one user in config/auth/users.yaml")
260
+ presentation_options(added)
261
+ added.add_argument("name", help="the name typed on the login page")
262
+ added.add_argument("--root", default=None, help="project directory that contains config/ (default: the current directory)")
263
+ listed = account_commands.add_parser("list", help="list user names")
264
+ presentation_options(listed)
265
+ listed.add_argument("--root", default=None, help="project directory that contains config/ (default: the current directory)")
266
+
267
+ project = sub.add_parser(
268
+ "project",
269
+ help="create or validate a project directory",
270
+ description="A project holds tables, job YAML and a model library. The package stays separate.",
271
+ examples=[("Create", "beefcot project create HERD"),
272
+ ("Validate", "beefcot project validate HERD"),
273
+ ("Register", "beefcot project add HERD"),
274
+ ("On this machine", "beefcot project list")],
275
+ )
276
+ presentation_options(project)
277
+ project_commands = project.add_subparsers(dest="project_command", required=True)
278
+
279
+ def project_command(name: str, help_text: str, description: str) -> argparse.ArgumentParser:
280
+ made = project_commands.add_parser(name, help=help_text, description=description)
281
+ presentation_options(made)
282
+ made.add_argument("--projects", default=None, help="the machine project list (default: ~/.beefcot/projects.json)")
283
+ return made
284
+
285
+ created = project_command("create", "write a standard project and an empty model library",
286
+ "Write a standard project and an empty model library. "
287
+ "The directory must be missing or empty. No table is copied. "
288
+ "The new project is recorded in the machine list.")
289
+ created.add_argument("path", help="the new project directory")
290
+ checked = project_command("validate", "check a project directory and compute nothing",
291
+ "Check a project directory and compute nothing.")
292
+ checked.add_argument("path", nargs="?", default=None, help="the project directory (default: the current directory)")
293
+ project_command("list", "list the projects recorded on this machine",
294
+ "List the projects recorded on this machine.")
295
+ added = project_command("add", "record an existing project directory",
296
+ "Record an existing project directory. The directory is not copied.")
297
+ added.add_argument("path", help="the project directory")
298
+ removed = project_command("remove", "drop a project from the machine list",
299
+ "Drop a project from the machine list. The directory is kept.")
300
+ removed.add_argument("id", help="the project id")
189
301
 
190
302
  models = sub.add_parser("models", help="inspect, verify and move delivered models",
191
303
  description="Inspect, verify and move delivered models.",
@@ -224,6 +336,21 @@ def main(argv: Sequence[str] | None = None) -> int:
224
336
  parser.print_help()
225
337
  return 0
226
338
  args = parser.parse_args(raw)
339
+ if args.command == "serve":
340
+ from beefcot.service.errors import ServiceError
341
+ from beefcot.service.server import serve
342
+
343
+ try:
344
+ serve(Path(args.root).expanduser() if args.root else Path.cwd(), args.port,
345
+ design=args.design, projects=args.projects)
346
+ except ServiceError as failure:
347
+ print(failure.message, file=sys.stderr)
348
+ return 1
349
+ except KeyboardInterrupt:
350
+ return 130
351
+ return 0
352
+ if args.command == "user":
353
+ return _user(args)
227
354
  ui.as_json = getattr(args, 'json', False)
228
355
  ui.no_color = getattr(args, 'no_color', False)
229
356
  quiet = getattr(args, 'quiet', False)
@@ -241,6 +368,8 @@ def main(argv: Sequence[str] | None = None) -> int:
241
368
  result = _produce(args)
242
369
  elif args.command == "validate":
243
370
  result = _validate(args)
371
+ elif args.command == "project":
372
+ result = _project(args)
244
373
  elif args.command == "models":
245
374
  result = _models(args)
246
375
  elif args.command == "kinds":
@@ -254,7 +383,12 @@ def main(argv: Sequence[str] | None = None) -> int:
254
383
  result = {"ok": False, "error": "Interrupted by user."}
255
384
  except Exception as failure: # one structured failure, without a traceback
256
385
  result = {"ok": False, "error": f"{type(failure).__name__}: {failure}"}
257
- label = f'models {args.models_command}' if args.command == 'models' else args.command
386
+ if args.command == "models":
387
+ label = f"models {args.models_command}"
388
+ elif args.command == "project":
389
+ label = f"project {args.project_command}"
390
+ else:
391
+ label = args.command
258
392
  ui.result(result, label, time.monotonic() - started)
259
393
  return 130 if interrupted else 0 if result["ok"] else 1
260
394
 
@@ -159,6 +159,33 @@ class Terminal:
159
159
  return
160
160
  if 'kinds' in result:
161
161
  rows = [(item['kind'], f"{item['command']}\n{item['does']}") for item in result['kinds']]
162
+ elif 'project' in result and 'workspace' in result:
163
+ rows = []
164
+ if result.get('id'):
165
+ rows.append(('Id', result['id']))
166
+ if result.get('name'):
167
+ rows.append(('Name', result['name']))
168
+ rows += [('Project', result['project']), ('Workspace', result['workspace'] or 'Missing')]
169
+ rows.append(('Libraries', '\n'.join(result.get('models') or []) or 'None'))
170
+ rows.append(('Config', ', '.join(result.get('config') or []) or 'None'))
171
+ if result.get('registry'):
172
+ rows.append(('Registry', result['registry']))
173
+ if elapsed is not None:
174
+ rows.append(('Elapsed', f'{elapsed:.2f}s'))
175
+ elif result.get('registry') and isinstance(result.get('projects'), list):
176
+ rows = [('Registry', result['registry'])]
177
+ if not result['projects']:
178
+ rows.append(('Projects', 'None'))
179
+ for item in result['projects']:
180
+ state = 'ready' if item.get('ready') else 'not ready'
181
+ rows.append((item['id'], f"{item.get('name') or item['id']}\n{item.get('path', '')}\n{state}"))
182
+ elif result.get('registry') and result.get('id'):
183
+ rows = [('Id', result['id'])]
184
+ if result.get('name'):
185
+ rows.append(('Name', result['name']))
186
+ if result.get('path'):
187
+ rows.append(('Path', result['path']))
188
+ rows.append(('Registry', result['registry']))
162
189
  elif isinstance(result.get('models'), list) and 'libraries' in result:
163
190
  rows = [('Library', place) for place in result['libraries']]
164
191
  rows += [(f'{index:02d}', f"{item['id']}\n{item['producer']} / {item['products']} products / {item['created_at'][:10]}")
@@ -195,7 +222,9 @@ class Terminal:
195
222
  rows.append(('Report', str(Path(output) / 'report.html')))
196
223
  if elapsed is not None:
197
224
  rows.append(('Elapsed', f'{elapsed:.2f}s'))
198
- titles = {'validate': 'VALID YAML', 'kinds': 'YAML KINDS', 'models list': 'MODEL LIBRARY',
225
+ titles = {'validate': 'VALID YAML', 'kinds': 'YAML KINDS',
226
+ 'project list': 'PROJECTS', 'project add': 'PROJECT ADDED', 'project remove': 'PROJECT REMOVED',
227
+ 'models list': 'MODEL LIBRARY',
199
228
  'models show': 'MODEL DETAILS', 'models verify': 'MODEL VERIFIED',
200
229
  'models import': 'MODEL IMPORTED', 'models export': 'MODEL EXPORTED'}
201
230
  self.write(self.panel(titles.get(command, f'{command.upper()} COMPLETE'), rows, color='32'), self.out)
@@ -67,11 +67,17 @@ class Pattern:
67
67
 
68
68
  @classmethod
69
69
  def from_dict(cls, payload: Mapping[str, Any]) -> "Pattern":
70
+ if not isinstance(payload, Mapping):
71
+ raise TypeError("a pattern must be a mapping")
70
72
  allowed = {"name", "products", "process_speed", "hourly_labor_cost", "min_carcasses",
71
73
  "max_carcasses", "prices"}
72
74
  unknown = sorted(set(payload) - allowed)
73
75
  if unknown:
74
76
  raise ValueError(f"pattern {payload.get('name')!r}: unknown keys={unknown}; allowed={sorted(allowed)}")
77
+ required = ("name", "products", "process_speed", "hourly_labor_cost")
78
+ missing = [key for key in required if key not in payload]
79
+ if missing:
80
+ raise ValueError(f"pattern {payload.get('name', '?')!r}: missing keys={missing}")
75
81
  return cls(
76
82
  name=str(payload["name"]),
77
83
  products=tuple(str(item) for item in payload["products"]),
@@ -138,6 +144,8 @@ class ScenarioSpec:
138
144
 
139
145
  @classmethod
140
146
  def from_dict(cls, payload: Mapping[str, Any]) -> "ScenarioSpec":
147
+ if not isinstance(payload, Mapping):
148
+ raise TypeError("scenario must be a mapping")
141
149
  allowed = {
142
150
  "patterns", "prices", "pieces_per_carcass", "purchase_price_column", "purchase_price",
143
151
  "weight_column", "available_column", "uncovered_products", "assignment",
@@ -145,6 +153,9 @@ class ScenarioSpec:
145
153
  unknown = sorted(set(payload) - allowed)
146
154
  if unknown:
147
155
  raise ValueError(f"scenario: unknown keys={unknown}; allowed={sorted(allowed)}")
156
+ missing = [key for key in ("patterns", "prices") if key not in payload]
157
+ if missing:
158
+ raise ValueError(f"scenario: missing keys={missing}")
148
159
  return cls(
149
160
  patterns=tuple(Pattern.from_dict(item) for item in payload["patterns"]),
150
161
  prices={str(key): float(value) for key, value in dict(payload["prices"]).items()},
@@ -381,6 +392,63 @@ def _plain(value: Any) -> Any:
381
392
  return value.item() if hasattr(value, "item") else value
382
393
 
383
394
 
395
+ def random_assignment_comparison(
396
+ inputs: CuttingPlanInputs,
397
+ assignment: pd.DataFrame | None,
398
+ ) -> tuple[dict[str, Any], pd.DataFrame]:
399
+ """What uniform random cutting returns, beside the solved plan.
400
+
401
+ Each available carcass independently takes one pattern with equal probability. That is the
402
+ expectation, not one shuffled draw. A pattern minimum or maximum is not applied: those limits
403
+ couple the carcasses, and this number is the mean of the unrestricted draws. Purchase is paid
404
+ for every carcass, because a random cut still processes it. Profit, income and labour are the
405
+ per-carcass figures times how many carcasses the row stands for.
406
+ """
407
+ profit = inputs.profit
408
+ patterns = list(profit.columns)
409
+ if not patterns:
410
+ raise ValueError("a random comparison needs at least one pattern")
411
+ available = inputs.available.reindex(profit.index).astype(float)
412
+ expected_profit = profit.mean(axis=1) * available
413
+ expected_income = inputs.income.reindex(columns=patterns).mean(axis=1) * available
414
+ expected_labor = inputs.labor_cost.reindex(columns=patterns).mean(axis=1) * available
415
+ expected_purchase = inputs.purchase_cost.reindex(profit.index).astype(float) * available
416
+ limits = any(
417
+ pattern.min_carcasses is not None or pattern.max_carcasses is not None
418
+ for pattern in inputs.patterns
419
+ )
420
+ solved = assignment is not None
421
+ if solved:
422
+ counts = assignment.reindex(index=profit.index, columns=patterns).fillna(0.0).astype(float)
423
+ chosen_profit = (profit * counts).sum(axis=1)
424
+ advantage = chosen_profit - expected_profit
425
+ else:
426
+ chosen_profit = pd.Series(np.nan, index=profit.index)
427
+ advantage = pd.Series(np.nan, index=profit.index)
428
+ by_carcass = pd.DataFrame(
429
+ {
430
+ "available": available.to_numpy(),
431
+ "expected_profit": expected_profit.to_numpy(),
432
+ "chosen_profit": chosen_profit.to_numpy(),
433
+ "advantage": advantage.to_numpy(),
434
+ },
435
+ index=profit.index,
436
+ )
437
+ total_carcasses = float(available.sum())
438
+ report = {
439
+ "rule": "each available carcass independently and uniformly among the patterns",
440
+ "patterns": len(patterns),
441
+ "respects_pattern_limits": not limits,
442
+ "expected_profit": float(expected_profit.sum()),
443
+ "expected_income": float(expected_income.sum()),
444
+ "expected_labor_cost": float(expected_labor.sum()),
445
+ "expected_purchase_cost": float(expected_purchase.sum()),
446
+ "expected_carcasses_per_pattern": total_carcasses / len(patterns),
447
+ "advantage_over_random": None if not solved else float(advantage.sum()),
448
+ }
449
+ return report, by_carcass
450
+
451
+
384
452
  def solve(inputs: CuttingPlanInputs) -> PlanResult:
385
453
  """Assign carcasses to patterns: a mixed-integer problem (one pattern per carcass, pattern
386
454
  capacities), solved exactly by HiGHS."""
@@ -31,7 +31,9 @@ from beefcot import runtime
31
31
  from beefcot.model_format.library import LibraryError, ModelLibrary
32
32
  from beefcot.runtime import MANIFEST as MANIFEST_NAME
33
33
  from beefcot.runtime import Model
34
- from beefcot.cutting_plan.economics import SOLVER, CuttingPlanInputs, ScenarioSpec, build_inputs, solve
34
+ from beefcot.cutting_plan.economics import (
35
+ SOLVER, CuttingPlanInputs, ScenarioSpec, build_inputs, random_assignment_comparison, solve,
36
+ )
35
37
  from beefcot.yamlio import block, fresh_dir, from_yaml, load_yaml
36
38
 
37
39
  KIND = "cutting_plan"
@@ -278,6 +280,15 @@ def run(config: str | Path) -> Path:
278
280
  f"they cover as much as by value; compare patterns that break up the same cuts, or "
279
281
  f"supply the rest with uncovered_products: external"
280
282
  )
283
+ random_report, random_by_carcass = random_assignment_comparison(
284
+ inputs, result.assignment if solved else None
285
+ )
286
+ random_by_carcass.to_csv(root / "random_comparison.csv", index_label="carcass")
287
+ if not random_report["respects_pattern_limits"]:
288
+ warnings.append(
289
+ "the random expectation assigns every carcass uniformly and does not enforce a pattern's "
290
+ "minimum or maximum; the advantage is against that unrestricted draw"
291
+ )
281
292
  unusual = inputs.provenance.get("unusual_inputs", {})
282
293
  for column, entry in unusual.items():
283
294
  warnings.append(
@@ -293,6 +304,7 @@ def run(config: str | Path) -> Path:
293
304
  "solver": result.solver,
294
305
  "seconds": result.seconds,
295
306
  "objective_profit": result.objective,
307
+ "random_assignment": random_report,
296
308
  "carcasses": int(len(carcasses)),
297
309
  "carcasses_assigned": int(assigned.to_numpy().sum()),
298
310
  "per_pattern": per_pattern,
@@ -521,8 +521,17 @@ def _fit_lstsq(adapter: "NodeRegressorAdapter", X: np.ndarray, y: np.ndarray) ->
521
521
  adapter.beta = linalg.lstsq(X, y)[0]
522
522
 
523
523
 
524
+ class NNLSConvergenceError(ValueError):
525
+ """A known, non-computable candidate fit; other runtime errors still propagate."""
526
+
527
+
524
528
  def _fit_nnls(adapter: "NodeRegressorAdapter", X: np.ndarray, y: np.ndarray) -> None:
525
- adapter.beta = optimize.nnls(X, y)[0]
529
+ try:
530
+ adapter.beta = optimize.nnls(X, y)[0]
531
+ except RuntimeError as exc:
532
+ if str(exc) != "Maximum number of iterations reached.":
533
+ raise
534
+ raise NNLSConvergenceError(f"nnls convergence failure: {exc}") from exc
526
535
 
527
536
 
528
537
  def _fit_robust(adapter: "NodeRegressorAdapter", X: np.ndarray, y: np.ndarray) -> None:
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import warnings
13
13
  from dataclasses import dataclass
14
+ from functools import lru_cache
14
15
  from typing import Sequence
15
16
 
16
17
  import numpy as np
@@ -180,8 +181,21 @@ def _boxcox_lambda(values: np.ndarray, bounds: tuple[float, float] = (-2.0, 2.0)
180
181
  directly rather than reimplemented, and clamped because an unbounded lambda
181
182
  on 190 rows can wander far enough to make the inverse overflow.
182
183
  """
184
+ values = np.asarray(values, dtype=float)
185
+ # The response and its fold, not the input mapping or learner, determine
186
+ # lambda. Key by every training value (in order) and both bounds: held-out
187
+ # data, other folds and other targets can never share fitted state by name.
188
+ # Bytes also bound the retained data without keeping whole frames alive.
189
+ return _boxcox_lambda_cached(values.tobytes(), tuple(bounds))
190
+
191
+
192
+ @lru_cache(maxsize=128)
193
+ def _boxcox_lambda_cached(training_values: bytes, bounds: tuple[float, float]) -> float:
194
+ """Process-local, bounded memoization of the unchanged deterministic fit."""
183
195
  from scipy import stats
184
196
 
197
+ values = np.frombuffer(training_values, dtype=float)
198
+
185
199
  try:
186
200
  lam = float(stats.boxcox_normmax(np.asarray(values, dtype=float), brack=bounds))
187
201
  except (ValueError, ArithmeticError, RuntimeError): # pragma: no cover - degenerate response
@@ -108,7 +108,7 @@ def flat_tree(tree: Mapping[str, Any]) -> dict[str, list]:
108
108
  return flat
109
109
 
110
110
 
111
- def network_block(fitted, products: Sequence[str]) -> dict[str, Any]:
111
+ def network_block(fitted, products: Sequence[str], *, selected: Sequence[str] | None = None) -> dict[str, Any]:
112
112
  """A shared network (``beefcot.producers.network.FittedNetwork``) as one block: its design, its
113
113
  members' weights, one output per product, each put back in its product's unit by the
114
114
  standardisation the targets were fitted in."""
@@ -116,7 +116,7 @@ def network_block(fitted, products: Sequence[str]) -> dict[str, Any]:
116
116
  mean, scale = [float(v) for v in fitted.target_mean], [float(v) for v in fitted.target_scale]
117
117
  if list(fitted.outputs) != list(products) or len(mean) != len(products):
118
118
  raise ValueError(f"the network gives {list(fitted.outputs)}, not {list(products)}")
119
- return {
119
+ block = {
120
120
  "terms": terms_of(design),
121
121
  "center": list(design.center),
122
122
  "scale": list(design.scale),
@@ -135,3 +135,13 @@ def network_block(fitted, products: Sequence[str]) -> dict[str, Any]:
135
135
  for i, code in enumerate(products)
136
136
  ],
137
137
  }
138
+ if selected is not None:
139
+ selected = list(selected)
140
+ if not selected or len(set(selected)) != len(selected) or not set(selected) <= set(products):
141
+ raise ValueError("selected network outputs must be distinct declared products")
142
+ indices = [list(products).index(code) for code in selected]
143
+ block["outputs"] = [block["outputs"][i] for i in indices]
144
+ for member in block["kernel"]["members"]:
145
+ member["weights"][-1] = [[row[i] for i in indices] for row in member["weights"][-1]]
146
+ member["biases"][-1] = [member["biases"][-1][i] for i in indices]
147
+ return block