sraivante commited on
Commit
e2cb597
·
verified ·
1 Parent(s): 155ea2f

v3.0.0: 1.1M-param numpy home-command parser with conditional rules, examples, Apache-2.0

Browse files
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ # --trusted-host: TLS-inspecting proxies/AV (e.g. Norton Web Shield) re-sign pypi.org
5
+ # with a CA the container does not have; harmless on clean networks.
6
+ RUN pip install --no-cache-dir \
7
+ --trusted-host pypi.org --trusted-host files.pythonhosted.org \
8
+ numpy flask
9
+
10
+ COPY engine.py server.py ./
11
+ COPY static/ static/
12
+ COPY model/ model/
13
+
14
+ ENV PYTHONUNBUFFERED=1
15
+ EXPOSE 8080
16
+ CMD ["python", "server.py"]
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - hi
6
+ pipeline_tag: text2text-generation
7
+ tags:
8
+ - text-to-json
9
+ - home-automation
10
+ - robotics
11
+ - hinglish
12
+ - tiny-model
13
+ - from-scratch
14
+ - edge-ai
15
+ - numpy
16
+ - raspberry-pi
17
+ - conditional-rules
18
+ - experimental
19
+ datasets:
20
+ - sraivante/home-commands-json-v3
21
+ metrics:
22
+ - exact_match
23
+ model-index:
24
+ - name: superfast-tiny-home-robotics-json-1m-v3
25
+ results:
26
+ - task:
27
+ type: text2text-generation
28
+ name: instruction to JSON device command
29
+ dataset:
30
+ type: sraivante/home-commands-json-v3
31
+ name: home-commands-json-v3 (1,500 held-out rows, greedy decode)
32
+ metrics:
33
+ - type: exact_match
34
+ value: 96.73
35
+ name: exact match (all)
36
+ verified: false
37
+ - type: exact_match
38
+ value: 96.89
39
+ name: exact match (plain commands, n=900)
40
+ verified: false
41
+ - type: exact_match
42
+ value: 96.50
43
+ name: exact match (conditional rules, n=600)
44
+ verified: false
45
+ ---
46
+
47
+ # Superfast Tiny Home Robotics JSON 1M v3
48
+
49
+ A **1,143,328-parameter character-level transformer, trained from scratch**, that
50
+ turns an English or Hinglish sentence into one JSON device command, including
51
+ **conditional rules** ("if temperature goes above 42...", "roz shaam 7 baje...").
52
+ Weights are **2.1 MB (fp16)**. Inference is **numpy only**: no PyTorch, no
53
+ tokenizer library, no internet, no GPU.
54
+
55
+ ```text
56
+ "bhai bedroom ka pankha high pe chala do"
57
+ -> {"activity":"air","subject":"bedroom_ceiling_fan","action":"HIGH"}
58
+
59
+ "agar room ka temperature 42 se upar jaye to bedroom ka heater band kar do"
60
+ -> {"activity":"heating","subject":"bedroom_room_heater","action":"OFF",
61
+ "when":{"metric":"temperature","op":">","value":42}}
62
+
63
+ "roz shaam 7 baje garden ki batti chala do"
64
+ -> {"activity":"light","subject":"garden_light","action":"ON",
65
+ "when":{"metric":"time","op":"==","value":"19:00"}}
66
+
67
+ "when soil moisture drops below 20 start the garden sprinkler"
68
+ -> {"activity":"garden","subject":"garden_sprinkler","action":"ON",
69
+ "when":{"metric":"soil_moisture","op":"<","value":20}}
70
+ ```
71
+
72
+ This is the v3 of the series. Compared with
73
+ [v1](https://huggingface.co/sraivante/superfast-tiny-home-robotics-json-1m-v1)
74
+ (30 commands, 10 device types, transformers runtime) it covers **1,004 devices,
75
+ 12 actions, 7 sensor metrics for rules**, and ships with a numpy engine plus a
76
+ web UI and Docker image.
77
+
78
+ | Release | Value |
79
+ | --- | --- |
80
+ | Publisher | sraivante |
81
+ | Release date | 2026-09-26 |
82
+ | Version | v3.0.0 |
83
+ | Training data | [home-commands-json-v3](https://huggingface.co/datasets/sraivante/home-commands-json-v3) (synthetic, templated) |
84
+ | Architecture | GPT-style causal decoder, char-level vocab (78 symbols), d=160, 4 layers, 4 heads, FFN 512, context 360 |
85
+ | Parameters | 1,143,328 |
86
+ | Weights | `model/model_v3_fp16.npz`, 2,146,783 bytes |
87
+ | Runtime deps | `numpy` (plus `flask` for the optional server) |
88
+ | License | Apache-2.0 |
89
+
90
+ ## Why a tiny model and not an LLM
91
+
92
+ A home robot or a Raspberry Pi behind a microphone does not need to know the
93
+ capital of France. It needs to turn "pankha tez kar do" into a command in a few
94
+ milliseconds, offline, on a CPU that is also doing wake-word detection. This
95
+ model is that one component: the **parser** between speech-to-text and the
96
+ actuator. Everything else (safety, permissions, device state) stays in
97
+ deterministic code you control.
98
+
99
+ Measured on this release, single request, Python 3.13, numpy 2.3, Intel
100
+ Core i7-1360P, default threads:
101
+
102
+ | Input | Latency |
103
+ | --- | ---: |
104
+ | plain command (about 30 chars) | 70 to 90 ms |
105
+ | conditional rule (about 70 chars) | 130 to 150 ms |
106
+ | process RSS, engine loaded + warm (Python + numpy included) | 73 MB |
107
+
108
+ Latency is dominated by Python overhead per generated character, not by
109
+ FLOPs. Raspberry Pi 5 and Jetson Orin Nano numbers are not yet measured;
110
+ treat the table as desktop numbers.
111
+
112
+ ## Quick start
113
+
114
+ ```bash
115
+ pip install numpy
116
+ git clone https://huggingface.co/sraivante/superfast-tiny-home-robotics-json-1m-v3
117
+ cd superfast-tiny-home-robotics-json-1m-v3
118
+ python engine.py "kitchen ki light band kar do"
119
+ # {"activity":"light","subject":"kitchen_light","action":"OFF"}
120
+ ```
121
+
122
+ In Python:
123
+
124
+ ```python
125
+ import engine
126
+ engine.init("model") # loads the npz once
127
+ raw, confidence = engine.parse_scored("turn off the porch light")
128
+ # raw -> '{"activity":"light","subject":"porch_light","action":"OFF"}'
129
+ # confidence -> avg log-prob per generated char; near 0.0 = in-domain
130
+ ```
131
+
132
+ Web UI + REST API (Docker, no GPU):
133
+
134
+ ```bash
135
+ docker compose up --build # open http://localhost:8080
136
+ curl -s localhost:8080/api/parse -H 'Content-Type: application/json' \
137
+ -d '{"text":"if aqi goes above 250 set the bedroom air purifier on high"}'
138
+ ```
139
+
140
+ The API returns the command, `valid_json`, catalog `issues`, `confidence`,
141
+ `latency_ms` and a single `ok` flag. **Act only on `ok: true`.**
142
+
143
+ ## How it is meant to be used (examples/)
144
+
145
+ The model is a parser. `model/devices-catalog.json` is the law. Every example
146
+ below runs the output through catalog validation before anything moves.
147
+
148
+ | Example | What it shows |
149
+ | --- | --- |
150
+ | `examples/quickstart.py` | text in, validated command out, off-domain input rejected |
151
+ | `examples/rule_engine.py` | what to do with the `when` clause: store it, watch sensors, fire once per crossing |
152
+ | `examples/mqtt_bridge.py` | publish to `home/<subject>/set` for ESP32 / Tasmota / Zigbee2MQTT devices |
153
+ | `examples/home_assistant.py` | map subjects to Home Assistant entities and call services locally |
154
+
155
+ Run any of them from the repo root, all have a `--dry-run` or simulated mode:
156
+
157
+ ```bash
158
+ python examples/quickstart.py
159
+ python examples/rule_engine.py
160
+ python examples/mqtt_bridge.py --dry-run "bedroom ka pankha high pe chala do"
161
+ python examples/home_assistant.py --dry-run "garden ki light band kar do"
162
+ ```
163
+
164
+ ### The pipeline this slots into
165
+
166
+ ```text
167
+ mic -> wake word -> Whisper/Vosk (Hindi+English) -> THIS MODEL -> catalog check -> actuator
168
+ ~100 ms ~0 ms GPIO / MQTT / ROS
169
+ ```
170
+
171
+ Plain commands (no `when`) go straight to the actuator. Conditional commands are
172
+ stored as rules and evaluated on every sensor tick. `rule_engine.py` output on a
173
+ simulated day:
174
+
175
+ ```text
176
+ "agar temperature 35 se upar jaye to bedroom ka ac high kar do"
177
+ RULE stored: bedroom_ac -> HIGH when temperature > 35
178
+ "roz subah 6 baje garden sprinkler chalu kar do"
179
+ RULE stored: garden_sprinkler -> ON when time == 06:00
180
+
181
+ [sensor] temperature = 36
182
+ ACTUATE bedroom_ac HIGH (temperature=36 > 35)
183
+ [sensor] time = 06:00
184
+ ACTUATE garden_sprinkler ON (time=06:00 == 06:00)
185
+ ```
186
+
187
+ ### Robotics use
188
+
189
+ For a mobile robot or a robotic arm the same pattern applies: swap the device
190
+ catalog for your robot's capabilities (`arm_gripper: OPEN/CLOSE`,
191
+ `base_motor: START/STOP/LOW/MEDIUM/HIGH`, `head_camera: ON/OFF/STATUS`),
192
+ regenerate the data with the scripts in the dataset repo, retrain for about an
193
+ hour on two CPU cores, and you have a command parser that fits in the L2 cache.
194
+ The `when` clause maps directly to sensor-gated behaviours
195
+ ("agar battery 15 se neeche jaye to charging dock pe jao").
196
+
197
+ ## Output schema
198
+
199
+ ```json
200
+ {"activity": "...", "subject": "...", "action": "...",
201
+ "when": {"metric": "...", "op": ">|<|==", "value": 42}}
202
+ ```
203
+
204
+ - **1,004 subjects** in `model/devices-catalog.json`, each with its `activity`
205
+ (18 of them: light, power, air, access, security, air_quality, cooling,
206
+ heating, entertainment, music, garden, kitchen, office, network, water,
207
+ laundry, cleaning, aquarium) and its allowed actions.
208
+ - **12 actions**: ON, OFF, START, STOP, LOW, MEDIUM, HIGH, OPEN, CLOSE, LOCK,
209
+ UNLOCK, STATUS.
210
+ - **7 metrics** for `when`: temperature, humidity, aqi, battery, water_level,
211
+ soil_moisture, time (value `"HH:MM"`, op `==`).
212
+ - `when` is optional. Rules in the training data are sensible by construction:
213
+ high temperature turns cooling ON or heating OFF, never the reverse.
214
+
215
+ ## Confidence guard
216
+
217
+ `parse_scored` returns the mean log-probability of the generated characters.
218
+ In-domain commands decode at about 0.0; gibberish or off-domain sentences
219
+ score clearly lower and the server flags them (default threshold -0.006, env
220
+ `HCAI_CONF_THRESHOLD`). Example: "what is the capital of france" scores -0.022
221
+ and is rejected. This is a useful guard, not a guarantee: command-shaped
222
+ off-domain input can still slip through.
223
+
224
+ ## Evaluation
225
+
226
+ 1,500 held-out rows from the same generator (900 plain, 600 conditional),
227
+ greedy decode, exact string match on the JSON:
228
+
229
+ | Metric | Value |
230
+ | --- | ---: |
231
+ | exact match, all | 96.73 % |
232
+ | exact match, plain | 96.89 % |
233
+ | exact match, conditional | 96.50 % |
234
+ | valid JSON | 100 % |
235
+
236
+ Full numbers and 12 sample failures are in `model/metrics_v3.json`. The
237
+ held-out rows themselves were not preserved in this release; the dataset repo
238
+ ships the generators and the deterministic `prep_mix.py` (seed 108) that
239
+ produced the train/eval split.
240
+
241
+ ## Honest limitations
242
+
243
+ - **Synthetic, templated training data.** The accuracy above measures
244
+ interpolation inside the template space, not the wild. Novel or indirect
245
+ phrasing ("andhera kar do", "thoda tez") fails.
246
+ - **Observed misses that pass validation.** "kitchen ki chimney high pe karo"
247
+ returned `STATUS` in our tests, and that is an allowed action for the
248
+ chimney, so the catalog check passed it. Treat STATUS on an imperative
249
+ sentence with suspicion in your own guard.
250
+ - **Char-level spelling errors** in subjects happen (see sample failures);
251
+ the catalog check exists exactly for that. Reject anything with issues.
252
+ - **No negation, no reject class.** "fan mat chalao" can become `ON`.
253
+ - **One instruction, one command.** No multi-intent, no context or pronouns.
254
+ - Time rules are one-shot `==` triggers; recurrence ("roz") is not encoded
255
+ separately, your scheduler decides that.
256
+ - Romanised Hindi only. Devanagari input is not in the vocabulary and is
257
+ silently dropped by the tokenizer.
258
+
259
+ ## Adapting to your devices
260
+
261
+ 1. Edit the device tables in `generators/generate_dataset_v2.py` and the rule
262
+ tables in `generators/generate_dataset_v3_conditional.py` (dataset repo).
263
+ 2. Regenerate and mix: `python generate_dataset_v2.py && python generate_dataset_v3_conditional.py && python prep_mix.py`
264
+ 3. Retrain (needs torch; about 80 minutes on two CPU cores, minutes on a GPU):
265
+ `python training/train_v3.py train --seconds 4200 && python training/train_v3.py export`
266
+ 4. Drop `model_v3_fp16.npz`, `model_v3_config.json` and the new
267
+ `devices-catalog.json` into `model/`.
268
+
269
+ ## Files
270
+
271
+ | Path | What |
272
+ | --- | --- |
273
+ | `model/model_v3_fp16.npz` | weights, fp16, 2.1 MB |
274
+ | `model/model_v3_config.json` | vocab (`itos`) + architecture config |
275
+ | `model/devices-catalog.json` | the 1,004 subjects with allowed actions |
276
+ | `model/metrics_v3.json` | eval numbers and sample failures |
277
+ | `engine.py` | numpy inference, kv-cached greedy decode |
278
+ | `server.py`, `static/index.html` | Flask API + offline web UI |
279
+ | `examples/` | quickstart, rule engine, MQTT bridge, Home Assistant bridge |
280
+ | `training/train_v3.py` | full PyTorch training and export source |
281
+ | `Dockerfile`, `docker-compose.yml` | 150 MB image, healthcheck included |
282
+
283
+ ## License
284
+
285
+ Apache-2.0 for the weights, code and catalog. The training data is fully
286
+ synthetic and released under the same license in the dataset repo. No
287
+ warranty: this model suggests commands, your code decides whether to obey.
__pycache__/engine.cpython-313.pyc ADDED
Binary file (8.25 kB). View file
 
docker-compose.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ home-command-ai:
3
+ build: .
4
+ container_name: home-command-ai
5
+ ports:
6
+ - "8080:8080"
7
+ restart: unless-stopped
8
+ healthcheck:
9
+ test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/api/health')"]
10
+ interval: 30s
11
+ timeout: 5s
12
+ retries: 3
engine.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Numpy-only inference engine for the home-command LM (v3: plain + conditional).
4
+ No torch at runtime. Loads model/ (npz weights + config) once, then parse().
5
+ """
6
+
7
+ import json
8
+ import os
9
+
10
+ import numpy as np
11
+
12
+ _W = None
13
+ _CFG = None
14
+ _ITOS = None
15
+ _STOI = None
16
+
17
+
18
+ def init(model_dir=None):
19
+ global _W, _CFG, _ITOS, _STOI
20
+ model_dir = model_dir or os.path.join(os.path.dirname(os.path.abspath(__file__)), "model")
21
+ _W = {k: v.astype(np.float32)
22
+ for k, v in np.load(os.path.join(model_dir, "model_v3_fp16.npz")).items()}
23
+ _CFG = json.load(open(os.path.join(model_dir, "model_v3_config.json")))
24
+ _ITOS = _CFG["itos"]
25
+ _STOI = {c: i for i, c in enumerate(_ITOS)}
26
+
27
+
28
+ def _ln(x, w, b, eps=1e-5):
29
+ m = x.mean(-1, keepdims=True)
30
+ v = ((x - m) ** 2).mean(-1, keepdims=True)
31
+ return (x - m) / np.sqrt(v + eps) * w + b
32
+
33
+
34
+ def _gelu(x):
35
+ return 0.5 * x * (1.0 + np.tanh(0.7978845608028654 * (x + 0.044715 * x ** 3)))
36
+
37
+
38
+ def _block(i, x, cache, cfg):
39
+ p = f"blocks.{i}."
40
+ T = x.shape[0]
41
+ H, D = cfg["HEADS"], cfg["D"]
42
+ HD = D // H
43
+ h = _ln(x, _W[p + "ln1.weight"], _W[p + "ln1.bias"])
44
+ qkv = h @ _W[p + "qkv.weight"].T + _W[p + "qkv.bias"]
45
+ q, k, v = np.split(qkv, 3, axis=-1)
46
+ q = q.reshape(T, H, HD).transpose(1, 0, 2)
47
+ k = k.reshape(T, H, HD).transpose(1, 0, 2)
48
+ v = v.reshape(T, H, HD).transpose(1, 0, 2)
49
+ if cache.get(i) is not None:
50
+ pk, pv = cache[i]
51
+ k = np.concatenate([pk, k], axis=1)
52
+ v = np.concatenate([pv, v], axis=1)
53
+ cache[i] = (k, v)
54
+ Tk = k.shape[1]
55
+ att = q @ k.transpose(0, 2, 1) / np.sqrt(HD)
56
+ if T > 1:
57
+ mask = np.triu(np.full((T, Tk), -1e9, dtype=np.float32), k=Tk - T + 1)
58
+ att = att + mask[None]
59
+ att = att - att.max(-1, keepdims=True)
60
+ att = np.exp(att)
61
+ att = att / att.sum(-1, keepdims=True)
62
+ out = (att @ v).transpose(1, 0, 2).reshape(T, D)
63
+ x = x + out @ _W[p + "proj.weight"].T + _W[p + "proj.bias"]
64
+ h2 = _ln(x, _W[p + "ln2.weight"], _W[p + "ln2.bias"])
65
+ return x + _gelu(h2 @ _W[p + "fc1.weight"].T + _W[p + "fc1.bias"]) @ _W[p + "fc2.weight"].T + _W[p + "fc2.bias"]
66
+
67
+
68
+ def _forward(tokens, cache, past_len):
69
+ cfg = _CFG["config"]
70
+ T = len(tokens)
71
+ x = _W["tok.weight"][tokens] + _W["pos.weight"][past_len:past_len + T]
72
+ for i in range(cfg["LAYERS"]):
73
+ x = _block(i, x, cache, cfg)
74
+ x = _ln(x, _W["lnf.weight"], _W["lnf.bias"])
75
+ return x[-1] @ _W["tok.weight"].T
76
+
77
+
78
+ def _logprob_of(logits, t):
79
+ m = logits.max()
80
+ return float(logits[t] - m - np.log(np.exp(logits - m).sum()))
81
+
82
+
83
+ def parse_scored(instruction, max_new=170):
84
+ """instruction -> (raw output string, avg logprob per generated char).
85
+
86
+ The avg logprob is a confidence signal: in-distribution instructions
87
+ decode near 0.0; gibberish or unknown phrasing scores clearly lower.
88
+ """
89
+ if _W is None:
90
+ init()
91
+ toks = [_STOI[c] for c in instruction if c in _STOI] + [_STOI["\t"]]
92
+ max_ctx = _W["pos.weight"].shape[0]
93
+ toks = toks[-(max_ctx - max_new - 1):]
94
+ cache = {}
95
+ logits = _forward(toks, cache, 0)
96
+ out, lps = [], []
97
+ past = len(toks)
98
+ nl = _STOI["\n"]
99
+ for _ in range(max_new):
100
+ t = int(logits.argmax())
101
+ lps.append(_logprob_of(logits, t))
102
+ if t == nl:
103
+ break
104
+ out.append(t)
105
+ logits = _forward([t], cache, past)
106
+ past += 1
107
+ conf = float(np.mean(lps)) if lps else -99.0
108
+ return "".join(_ITOS[t] for t in out), conf
109
+
110
+
111
+ def parse(instruction, max_new=170):
112
+ """instruction text -> raw model output string (should be JSON)."""
113
+ return parse_scored(instruction, max_new)[0]
114
+
115
+
116
+ if __name__ == "__main__":
117
+ import sys
118
+ init()
119
+ print(parse(" ".join(sys.argv[1:]) or "turn off the kitchen light"))
examples/__pycache__/hcai_client.cpython-313.pyc ADDED
Binary file (5.3 kB). View file
 
examples/hcai_client.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared helper for the examples: model + catalog validation in one object.
4
+
5
+ from hcai_client import Parser
6
+ p = Parser() # loads ../model by default
7
+ r = p.parse("bedroom ka pankha high pe chala do")
8
+ if r["ok"]:
9
+ act(r["command"])
10
+
11
+ The model is a *parser*. The catalog is the law: never actuate anything
12
+ unless r["ok"] is True.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ ROOT = os.path.dirname(HERE)
22
+ sys.path.insert(0, ROOT)
23
+
24
+ import engine # noqa: E402 (numpy-only inference, lives in the repo root)
25
+
26
+ METRICS = {"temperature", "humidity", "aqi", "battery", "water_level",
27
+ "soil_moisture", "time"}
28
+ OPS = {">", "<", "=="}
29
+
30
+
31
+ class Parser:
32
+ def __init__(self, model_dir=None, conf_threshold=-0.006):
33
+ model_dir = model_dir or os.path.join(ROOT, "model")
34
+ engine.init(model_dir)
35
+ cat = json.load(open(os.path.join(model_dir, "devices-catalog.json"), encoding="utf-8"))
36
+ self.devices = {d["subject"]: d for d in cat["catalog"]}
37
+ self.conf_threshold = conf_threshold
38
+ engine.parse("turn on the light") # warm-up
39
+
40
+ def validate(self, obj):
41
+ issues = []
42
+ if not isinstance(obj, dict):
43
+ return ["output is not a JSON object"]
44
+ if not {"activity", "subject", "action"}.issubset(obj):
45
+ issues.append("missing keys")
46
+ extra = set(obj) - {"activity", "subject", "action", "when"}
47
+ if extra:
48
+ issues.append(f"unexpected keys: {sorted(extra)}")
49
+ dev = self.devices.get(obj.get("subject"))
50
+ if dev is None:
51
+ issues.append(f"unknown subject '{obj.get('subject')}'")
52
+ else:
53
+ if obj.get("action") not in dev["actions"]:
54
+ issues.append(f"action '{obj.get('action')}' not allowed for "
55
+ f"{obj['subject']} (allowed: {dev['actions']})")
56
+ if obj.get("activity") != dev["activity"]:
57
+ issues.append(f"activity mismatch (catalog says '{dev['activity']}')")
58
+ w = obj.get("when")
59
+ if w is not None:
60
+ if not isinstance(w, dict) or set(w) != {"metric", "op", "value"}:
61
+ issues.append("bad 'when' clause shape")
62
+ else:
63
+ if w["metric"] not in METRICS:
64
+ issues.append(f"unknown metric '{w['metric']}'")
65
+ if w["op"] not in OPS:
66
+ issues.append(f"unknown op '{w['op']}'")
67
+ return issues
68
+
69
+ def parse(self, text):
70
+ t0 = time.time()
71
+ raw, conf = engine.parse_scored(text.strip())
72
+ ms = round((time.time() - t0) * 1000, 1)
73
+ try:
74
+ obj = json.loads(raw)
75
+ except Exception:
76
+ return {"ok": False, "raw": raw, "command": None, "confidence": conf,
77
+ "latency_ms": ms, "issues": ["model output is not valid JSON"]}
78
+ issues = self.validate(obj)
79
+ if conf < self.conf_threshold:
80
+ issues.append(f"low confidence ({conf:.4f}): probably not a device command")
81
+ return {"ok": not issues, "raw": raw, "command": obj, "confidence": round(conf, 5),
82
+ "latency_ms": ms, "issues": issues}
examples/home_assistant.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Text -> Home Assistant service call, fully local (no cloud LLM in the loop).
4
+
5
+ Map catalog subjects to your HA entity ids in ENTITY_MAP, then:
6
+
7
+ export HA_URL=http://homeassistant.local:8123 HA_TOKEN=<long-lived token>
8
+ python examples/home_assistant.py "drawing room ka pankha medium pe karo"
9
+ python examples/home_assistant.py --dry-run "garden ki light band kar do"
10
+
11
+ Only plain commands are sent; conditional ones are printed as a suggested
12
+ HA automation trigger (wire them into automations.yaml or a rule runner).
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ import urllib.request
20
+
21
+ from hcai_client import Parser
22
+
23
+ # subject (from model/devices-catalog.json) -> Home Assistant entity_id
24
+ ENTITY_MAP = {
25
+ "living_room_light": "light.living_room",
26
+ "drawing_room_ceiling_fan": "fan.drawing_room",
27
+ "bedroom_ac": "climate.bedroom",
28
+ "main_door_lock": "lock.main_door",
29
+ "garden_light": "switch.garden_light",
30
+ }
31
+
32
+ # (domain, action) -> (service, extra data)
33
+ SERVICE = {
34
+ ("light", "ON"): ("turn_on", {}), ("light", "OFF"): ("turn_off", {}),
35
+ ("switch", "ON"): ("turn_on", {}), ("switch", "OFF"): ("turn_off", {}),
36
+ ("fan", "ON"): ("turn_on", {}), ("fan", "OFF"): ("turn_off", {}),
37
+ ("fan", "LOW"): ("set_percentage", {"percentage": 33}),
38
+ ("fan", "MEDIUM"): ("set_percentage", {"percentage": 66}),
39
+ ("fan", "HIGH"): ("set_percentage", {"percentage": 100}),
40
+ ("climate", "ON"): ("turn_on", {}), ("climate", "OFF"): ("turn_off", {}),
41
+ ("climate", "HIGH"): ("set_fan_mode", {"fan_mode": "high"}),
42
+ ("lock", "LOCK"): ("lock", {}), ("lock", "UNLOCK"): ("unlock", {}),
43
+ }
44
+
45
+
46
+ def call_ha(domain, service, data, dry_run):
47
+ base = os.environ.get("HA_URL", "http://homeassistant.local:8123")
48
+ url = f"{base}/api/services/{domain}/{service}"
49
+ print(f"POST {url} {json.dumps(data)}")
50
+ if dry_run:
51
+ return
52
+ req = urllib.request.Request(
53
+ url, data=json.dumps(data).encode(), method="POST",
54
+ headers={"Authorization": f"Bearer {os.environ['HA_TOKEN']}",
55
+ "Content-Type": "application/json"})
56
+ with urllib.request.urlopen(req, timeout=5) as resp:
57
+ print(resp.status, resp.read()[:200])
58
+
59
+
60
+ def suggest_trigger(w):
61
+ if w["metric"] == "time":
62
+ return {"platform": "time", "at": w["value"]}
63
+ key = "above" if w["op"] == ">" else "below"
64
+ return {"platform": "numeric_state", "entity_id": f"sensor.{w['metric']}", key: w["value"]}
65
+
66
+
67
+ def main():
68
+ ap = argparse.ArgumentParser()
69
+ ap.add_argument("text", nargs="+")
70
+ ap.add_argument("--dry-run", action="store_true")
71
+ a = ap.parse_args()
72
+
73
+ r = Parser().parse(" ".join(a.text))
74
+ if not r["ok"]:
75
+ print("REJECTED:", r["issues"])
76
+ sys.exit(1)
77
+ cmd = r["command"]
78
+ print("parsed:", json.dumps(cmd), f"({r['latency_ms']} ms)")
79
+
80
+ if "when" in cmd:
81
+ print("conditional -> suggested automation trigger:", json.dumps(suggest_trigger(cmd["when"])))
82
+ return
83
+
84
+ entity = ENTITY_MAP.get(cmd["subject"])
85
+ if not entity:
86
+ print(f"no entity mapped for '{cmd['subject']}' - add it to ENTITY_MAP")
87
+ sys.exit(2)
88
+ domain = entity.split(".")[0]
89
+ svc = SERVICE.get((domain, cmd["action"]))
90
+ if not svc:
91
+ print(f"no service for {domain}/{cmd['action']}")
92
+ sys.exit(2)
93
+ call_ha(domain, svc[0], {"entity_id": entity, **svc[1]}, a.dry_run)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
examples/mqtt_bridge.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Text -> MQTT. Publishes validated commands to `home/<subject>/set` so any
4
+ ESP32 / Tasmota / Zigbee2MQTT device can subscribe and act.
5
+
6
+ pip install paho-mqtt
7
+ python examples/mqtt_bridge.py --dry-run "kitchen ki chimney high pe karo"
8
+ python examples/mqtt_bridge.py --broker 192.168.1.10 "turn off the porch light"
9
+
10
+ Conditional commands are published to `home/rules/add` for a rule runner
11
+ (see rule_engine.py) instead of being executed directly.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+
18
+ from hcai_client import Parser
19
+
20
+
21
+ def main():
22
+ ap = argparse.ArgumentParser()
23
+ ap.add_argument("text", nargs="+")
24
+ ap.add_argument("--broker", default="localhost")
25
+ ap.add_argument("--port", type=int, default=1883)
26
+ ap.add_argument("--dry-run", action="store_true", help="print, do not publish")
27
+ a = ap.parse_args()
28
+
29
+ r = Parser().parse(" ".join(a.text))
30
+ if not r["ok"]:
31
+ print("REJECTED:", r["issues"], "| raw:", r["raw"])
32
+ sys.exit(1)
33
+
34
+ cmd = r["command"]
35
+ if "when" in cmd:
36
+ topic, payload = "home/rules/add", json.dumps(cmd)
37
+ else:
38
+ topic, payload = f"home/{cmd['subject']}/set", cmd["action"]
39
+
40
+ print(f"PUBLISH {topic} <- {payload} ({r['latency_ms']} ms)")
41
+ if a.dry_run:
42
+ return
43
+ import paho.mqtt.publish as publish
44
+ publish.single(topic, payload, hostname=a.broker, port=a.port, qos=1, retain=False)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
examples/quickstart.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smallest possible use: text in, validated JSON command out.
4
+
5
+ python examples/quickstart.py
6
+ python examples/quickstart.py "agar aqi 200 se upar jaye to purifier high kar do"
7
+ """
8
+
9
+ import json
10
+ import sys
11
+
12
+ from hcai_client import Parser
13
+
14
+ DEMO = [
15
+ "bhai bedroom ka pankha high pe chala do",
16
+ "turn off the kitchen light",
17
+ "agar room ka temperature 42 se upar jaye to bedroom ka heater band kar do",
18
+ "roz shaam 7 baje garden ki batti chala do",
19
+ "when soil moisture drops below 20 start the garden sprinkler",
20
+ "what is the capital of france", # off-domain: must be rejected
21
+ ]
22
+
23
+ p = Parser()
24
+ for text in (sys.argv[1:] or DEMO):
25
+ r = p.parse(text)
26
+ flag = "OK " if r["ok"] else "REJ"
27
+ print(f"{flag} {r['latency_ms']:6.1f}ms {text}")
28
+ print(f" -> {json.dumps(r['command'], ensure_ascii=False)}")
29
+ if r["issues"]:
30
+ print(f" issues: {r['issues']}")
examples/rule_engine.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ What to do with the "when" clause: a tiny rule engine.
4
+
5
+ Plain commands fire immediately. Conditional commands are stored as rules and
6
+ evaluated every time a sensor reading (or the clock) changes. This is the
7
+ whole point of the v3 model: the user speaks once, the device obeys forever.
8
+
9
+ python examples/rule_engine.py # runs a simulated day
10
+
11
+ Replace `actuate()` with your GPIO / MQTT / ROS call and `SENSOR_FEED` with
12
+ your real sensors.
13
+ """
14
+
15
+ import time
16
+
17
+ from hcai_client import Parser
18
+
19
+ OPS = {">": lambda a, b: a > b, "<": lambda a, b: a < b, "==": lambda a, b: a == b}
20
+
21
+
22
+ def actuate(cmd, why):
23
+ # >>> your hardware goes here <<<
24
+ print(f" ACTUATE {cmd['subject']:<28} {cmd['action']:<7} ({why})")
25
+
26
+
27
+ class Home:
28
+ def __init__(self):
29
+ self.rules = [] # stored conditional commands
30
+ self.state = {} # latest sensor readings, e.g. {"temperature": 31}
31
+
32
+ def say(self, text, parser):
33
+ r = parser.parse(text)
34
+ print(f'\n"{text}"')
35
+ if not r["ok"]:
36
+ print(f" REJECTED: {r['issues']}")
37
+ return
38
+ cmd = r["command"]
39
+ if "when" not in cmd:
40
+ actuate(cmd, "direct command")
41
+ else:
42
+ w = cmd["when"]
43
+ self.rules.append({"cmd": cmd, "armed": True})
44
+ print(f" RULE stored: {cmd['subject']} -> {cmd['action']} "
45
+ f"when {w['metric']} {w['op']} {w['value']}")
46
+
47
+ def sensor(self, metric, value):
48
+ """Feed one reading; fire any rule whose condition just became true."""
49
+ self.state[metric] = value
50
+ for rule in self.rules:
51
+ w = rule["cmd"]["when"]
52
+ if w["metric"] != metric:
53
+ continue
54
+ hit = OPS[w["op"]](value, w["value"])
55
+ if hit and rule["armed"]:
56
+ actuate(rule["cmd"], f"{metric}={value} {w['op']} {w['value']}")
57
+ rule["armed"] = False # edge-triggered: fire once per crossing
58
+ elif not hit:
59
+ rule["armed"] = True # re-arm when condition clears
60
+
61
+
62
+ if __name__ == "__main__":
63
+ p = Parser()
64
+ home = Home()
65
+
66
+ home.say("living room ki light on kar do", p)
67
+ home.say("agar temperature 35 se upar jaye to bedroom ka ac high kar do", p)
68
+ home.say("when aqi goes above 200 turn on the hall air purifier", p)
69
+ home.say("roz subah 6 baje garden sprinkler chalu kar do", p)
70
+
71
+ print("\n--- simulated sensor feed ---")
72
+ SENSOR_FEED = [("temperature", 31), ("aqi", 120), ("temperature", 36),
73
+ ("time", "05:59"), ("time", "06:00"), ("aqi", 240),
74
+ ("temperature", 33), ("temperature", 37)]
75
+ for metric, value in SENSOR_FEED:
76
+ print(f"[sensor] {metric} = {value}")
77
+ home.sensor(metric, value)
78
+ time.sleep(0.05)
model/devices-catalog.json ADDED
The diff for this file is too large to render. See raw diff
 
model/metrics_v3.json ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "metrics": {
3
+ "eval_n": 1500,
4
+ "exact_match": 0.9673,
5
+ "valid_json": 1.0,
6
+ "plain_exact": 0.9689,
7
+ "conditional_exact": 0.965,
8
+ "plain_n": 900,
9
+ "conditional_n": 600,
10
+ "decode_seconds": 35.0,
11
+ "step": 6717
12
+ },
13
+ "sample_failures": [
14
+ {
15
+ "pred": "{\"activity\":\"cooling\",\"subject\":\"study_room_cooler\",\"action\":\"LOW\"}",
16
+ "gold": "{\"activity\":\"cooling\",\"subject\":\"study_room_cooler\",\"action\":\"HIGH\"}"
17
+ },
18
+ {
19
+ "pred": "{\"activity\":\"entertainment\",\"subject\":\"veranda_radio\",\"action\":\"STATUS\"}",
20
+ "gold": "{\"activity\":\"entertainment\",\"subject\":\"drawing_room_tv\",\"action\":\"STATUS\"}"
21
+ },
22
+ {
23
+ "pred": "{\"activity\":\"air_quality\",\"subject\":\"air_light\",\"action\":\"OFF\"}",
24
+ "gold": "{\"activity\":\"aquarium\",\"subject\":\"aquarium_light\",\"action\":\"OFF\"}"
25
+ },
26
+ {
27
+ "pred": "{\"activity\":\"power\",\"subject\":\"smart_plug_5\",\"action\":\"ON\"}",
28
+ "gold": "{\"activity\":\"power\",\"subject\":\"smart_plug_8\",\"action\":\"ON\"}"
29
+ },
30
+ {
31
+ "pred": "{\"activity\":\"air\",\"subject\":\"lobby_ceiling_fan\",\"action\":\"LOW\"}",
32
+ "gold": "{\"activity\":\"air\",\"subject\":\"lobby_ceiling_fan\",\"action\":\"ON\"}"
33
+ },
34
+ {
35
+ "pred": "{\"activity\":\"music\",\"subject\":\"master_bedroom_speaker\",\"action\":\"LOW\"}",
36
+ "gold": "{\"activity\":\"music\",\"subject\":\"master_bedroom_speaker\",\"action\":\"MEDIUM\"}"
37
+ },
38
+ {
39
+ "pred": "{\"activity\":\"water\",\"subject\":\"water_purifier\",\"action\":\"ON\"}",
40
+ "gold": "{\"activity\":\"power\",\"subject\":\"ups\",\"action\":\"ON\"}"
41
+ },
42
+ {
43
+ "pred": "{\"activity\":\"security\",\"subject\":\"camera_6\",\"action\":\"ON\",\"when\":{\"metric\":\"time\",\"op\":\"==\",\"value\":\"06:00\"}}",
44
+ "gold": "{\"activity\":\"security\",\"subject\":\"camera_1\",\"action\":\"ON\"}"
45
+ },
46
+ {
47
+ "pred": "{\"activity\":\"security\",\"subject\":\"camera_6\",\"action\":\"STATUS\"}",
48
+ "gold": "{\"activity\":\"security\",\"subject\":\"camera_1\",\"action\":\"STATUS\"}"
49
+ },
50
+ {
51
+ "pred": "{\"activity\":\"kitchen\",\"subject\":\"air_fryer\",\"action\":\"STOP\"}",
52
+ "gold": "{\"activity\":\"laundry\",\"subject\":\"dryer\",\"action\":\"STOP\"}"
53
+ },
54
+ {
55
+ "pred": "{\"activity\":\"security\",\"subject\":\"camera_6\",\"action\":\"STATUS\"}",
56
+ "gold": "{\"activity\":\"security\",\"subject\":\"camera_7\",\"action\":\"STATUS\"}"
57
+ },
58
+ {
59
+ "pred": "{\"activity\":\"access\",\"subject\":\"washroom_immersion_rodor_k\",\"action\":\"ON\"}",
60
+ "gold": "{\"activity\":\"heating\",\"subject\":\"washroom_immersion_rod\",\"action\":\"ON\"}"
61
+ }
62
+ ]
63
+ }
model/model_v3_config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"itos": ["<pad>", "\t", "\n", " ", "!", "\"", "'", ",", ".", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", "<", "=", ">", "?", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "_", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "{", "}"], "config": {"D": 160, "LAYERS": 4, "HEADS": 4, "FFN": 512, "MAX_LEN": 360}, "params": 1143328, "step": 6717}
model/model_v3_fp16.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fc749ecd89c1ae30a159d6d701036298b5583645b7fbf114311e006929c8778b
3
+ size 2146783
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ numpy>=1.24
2
+ flask>=3.0
server.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ home-command-ai server: web UI + JSON API around the tiny numpy LM.
4
+
5
+ GET / the UI
6
+ POST /api/parse {"text": "..."} -> parsed command + validation
7
+ GET /api/devices the device catalog (subjects, activities, allowed actions)
8
+ GET /api/health
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import time
14
+
15
+ from flask import Flask, jsonify, request, send_from_directory
16
+
17
+ import engine
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ app = Flask(__name__, static_folder=None)
21
+
22
+ engine.init()
23
+
24
+ CATALOG = json.load(open(os.path.join(HERE, "model", "devices-catalog.json")))
25
+ DEVICES = {d["subject"]: d for d in CATALOG["catalog"]}
26
+ METRICS = {"temperature", "humidity", "aqi", "battery", "water_level",
27
+ "soil_moisture", "time"}
28
+ OPS = {">", "<", "=="}
29
+ # avg logprob below this flags the parse as out-of-domain (see README)
30
+ CONF_THRESHOLD = float(os.environ.get("HCAI_CONF_THRESHOLD", "-0.006"))
31
+
32
+ # warm up (first call pays small numpy init costs)
33
+ engine.parse("turn on the light")
34
+
35
+
36
+ def validate(obj):
37
+ """schema + catalog checks -> list of issue strings (empty = fully valid)"""
38
+ issues = []
39
+ if not isinstance(obj, dict):
40
+ return ["output is not a JSON object"]
41
+ base_keys = {"activity", "subject", "action"}
42
+ if not base_keys.issubset(obj.keys()):
43
+ issues.append("missing keys")
44
+ extra = set(obj.keys()) - base_keys - {"when"}
45
+ if extra:
46
+ issues.append(f"unexpected keys: {sorted(extra)}")
47
+ subj = obj.get("subject")
48
+ dev = DEVICES.get(subj)
49
+ if dev is None:
50
+ issues.append(f"unknown subject '{subj}' (not in catalog)")
51
+ else:
52
+ if obj.get("action") not in dev["actions"]:
53
+ issues.append(f"action '{obj.get('action')}' not allowed for {subj} "
54
+ f"(allowed: {dev['actions']})")
55
+ if obj.get("activity") != dev["activity"]:
56
+ issues.append(f"activity mismatch (catalog says '{dev['activity']}')")
57
+ w = obj.get("when")
58
+ if w is not None:
59
+ if not isinstance(w, dict) or set(w.keys()) != {"metric", "op", "value"}:
60
+ issues.append("bad 'when' clause shape")
61
+ else:
62
+ if w["metric"] not in METRICS:
63
+ issues.append(f"unknown metric '{w['metric']}'")
64
+ if w["op"] not in OPS:
65
+ issues.append(f"unknown op '{w['op']}'")
66
+ return issues
67
+
68
+
69
+ @app.get("/")
70
+ def index():
71
+ return send_from_directory(os.path.join(HERE, "static"), "index.html")
72
+
73
+
74
+ @app.get("/api/health")
75
+ def health():
76
+ return jsonify({"ok": True, "devices": len(DEVICES)})
77
+
78
+
79
+ @app.get("/api/devices")
80
+ def devices():
81
+ return jsonify(CATALOG)
82
+
83
+
84
+ @app.post("/api/parse")
85
+ def api_parse():
86
+ body = request.get_json(force=True, silent=True) or {}
87
+ text = (body.get("text") or "").strip()
88
+ if not text:
89
+ return jsonify({"ok": False, "error": "empty text"}), 400
90
+ t0 = time.time()
91
+ raw, conf = engine.parse_scored(text)
92
+ latency_ms = round((time.time() - t0) * 1000, 1)
93
+ try:
94
+ obj = json.loads(raw)
95
+ valid_json = True
96
+ except Exception:
97
+ obj, valid_json = None, False
98
+ issues = validate(obj) if valid_json else ["model output is not valid JSON"]
99
+ if conf < CONF_THRESHOLD:
100
+ issues.append(f"low model confidence ({conf:.4f}) — input may be "
101
+ "out-of-domain; not a home-device command?")
102
+ return jsonify({
103
+ "ok": valid_json and not issues,
104
+ "raw": raw,
105
+ "command": obj,
106
+ "valid_json": valid_json,
107
+ "issues": issues,
108
+ "confidence": round(conf, 5),
109
+ "latency_ms": latency_ms,
110
+ })
111
+
112
+
113
+ if __name__ == "__main__":
114
+ port = int(os.environ.get("PORT", "8080"))
115
+ print(f"home-command-ai listening on http://0.0.0.0:{port}")
116
+ app.run(host="0.0.0.0", port=port)
static/index.html ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>home-command-ai</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117; --panel: #161b22; --panel2: #1c2129; --border: #2d333b;
10
+ --text: #e6edf3; --dim: #8b949e; --accent: #58a6ff; --ok: #3fb950;
11
+ --warn: #d29922; --bad: #f85149; --key: #79c0ff; --str: #a5d6ff;
12
+ --num: #ffa657; --chip: #21262d;
13
+ }
14
+ * { box-sizing: border-box; margin: 0; padding: 0; }
15
+ body {
16
+ background: var(--bg); color: var(--text);
17
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18
+ min-height: 100vh;
19
+ }
20
+ .wrap { max-width: 1060px; margin: 0 auto; padding: 24px 18px 60px; }
21
+ header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 6px; }
22
+ h1 { font-size: 22px; font-weight: 700; }
23
+ h1 .dot { color: var(--accent); }
24
+ .sub { color: var(--dim); font-size: 13px; }
25
+ .badges { margin-left: auto; display: flex; gap: 8px; }
26
+ .pill {
27
+ font-size: 11.5px; padding: 3px 10px; border-radius: 999px;
28
+ border: 1px solid var(--border); color: var(--dim); background: var(--panel);
29
+ }
30
+ .grid { display: grid; grid-template-columns: 1fr 340px; gap: 18px; margin-top: 18px; }
31
+ @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
32
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
33
+ .inputrow { display: flex; gap: 10px; }
34
+ #cmd {
35
+ flex: 1; background: var(--panel2); border: 1px solid var(--border);
36
+ border-radius: 10px; color: var(--text); padding: 12px 14px; font-size: 15px;
37
+ outline: none;
38
+ }
39
+ #cmd:focus { border-color: var(--accent); }
40
+ button {
41
+ background: var(--accent); color: #0d1117; font-weight: 700; border: 0;
42
+ border-radius: 10px; padding: 0 22px; font-size: 15px; cursor: pointer;
43
+ }
44
+ button:hover { filter: brightness(1.1); }
45
+ button:disabled { opacity: .5; cursor: wait; }
46
+ .chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
47
+ .chip {
48
+ background: var(--chip); border: 1px solid var(--border); color: var(--dim);
49
+ font-size: 12.5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
50
+ }
51
+ .chip:hover { color: var(--text); border-color: var(--accent); }
52
+ .result { margin-top: 16px; display: none; }
53
+ .status { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; align-items: center; }
54
+ .tag { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px; }
55
+ .tag.ok { background: rgba(63,185,80,.15); color: var(--ok); border: 1px solid rgba(63,185,80,.4); }
56
+ .tag.bad { background: rgba(248,81,73,.15); color: var(--bad); border: 1px solid rgba(248,81,73,.4); }
57
+ .tag.warn { background: rgba(210,153,34,.15); color: var(--warn); border: 1px solid rgba(210,153,34,.4); }
58
+ .tag.dim { background: var(--chip); color: var(--dim); border: 1px solid var(--border); font-weight: 400; }
59
+ pre {
60
+ background: var(--panel2); border: 1px solid var(--border); border-radius: 10px;
61
+ padding: 14px; overflow-x: auto; font: 13.5px/1.6 ui-monospace, "Cascadia Code", Menlo, monospace;
62
+ }
63
+ .k { color: var(--key); } .s { color: var(--str); } .n { color: var(--num); }
64
+ .issues { margin-top: 10px; font-size: 13px; color: var(--warn); }
65
+ .issues li { margin-left: 18px; }
66
+ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin-bottom: 10px; }
67
+ .hist-item {
68
+ padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13px;
69
+ color: var(--dim); border: 1px solid transparent; display: flex; gap: 8px; align-items: center;
70
+ }
71
+ .hist-item:hover { background: var(--panel2); border-color: var(--border); color: var(--text); }
72
+ .hist-item .st { width: 7px; height: 7px; border-radius: 50%; flex: none; }
73
+ #devsearch {
74
+ width: 100%; background: var(--panel2); border: 1px solid var(--border);
75
+ border-radius: 8px; color: var(--text); padding: 8px 12px; font-size: 13.5px;
76
+ outline: none; margin-bottom: 10px;
77
+ }
78
+ .dev { padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
79
+ .dev b { color: var(--text); font-weight: 600; display: block; font-family: ui-monospace, monospace; }
80
+ .dev span { color: var(--dim); }
81
+ #devlist { max-height: 420px; overflow-y: auto; }
82
+ .count { color: var(--dim); font-size: 12px; margin-bottom: 6px; }
83
+ footer { margin-top: 26px; color: var(--dim); font-size: 12.5px; text-align: center; }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div class="wrap">
88
+ <header>
89
+ <h1>home-command<span class="dot">-ai</span></h1>
90
+ <div class="sub">Hinglish + English &rarr; JSON device commands &amp; rules</div>
91
+ <div class="badges">
92
+ <span class="pill">1.1M params</span>
93
+ <span class="pill">char-level LM</span>
94
+ <span class="pill">100% offline</span>
95
+ </div>
96
+ </header>
97
+
98
+ <div class="grid">
99
+ <div>
100
+ <div class="card">
101
+ <div class="inputrow">
102
+ <input id="cmd" placeholder='try: "bhai bedroom ka pankha high pe chala do"' autocomplete="off">
103
+ <button id="go">Parse</button>
104
+ </div>
105
+ <div class="chips" id="examples"></div>
106
+ <div class="result" id="result">
107
+ <div class="status" id="status"></div>
108
+ <pre id="json"></pre>
109
+ <ul class="issues" id="issues"></ul>
110
+ </div>
111
+ </div>
112
+ <div class="card" style="margin-top:18px">
113
+ <h2>History</h2>
114
+ <div id="history"><div class="sub">Parsed commands will appear here.</div></div>
115
+ </div>
116
+ </div>
117
+ <div class="card">
118
+ <h2>Device catalog</h2>
119
+ <input id="devsearch" placeholder="search 1004 devices&hellip;">
120
+ <div class="count" id="devcount"></div>
121
+ <div id="devlist"></div>
122
+ </div>
123
+ </div>
124
+
125
+ <footer>
126
+ tiny char-level transformer &middot; trained on synthetic EN/Hinglish home-command data
127
+ &middot; validate every command against the catalog before acting on it
128
+ </footer>
129
+ </div>
130
+
131
+ <script>
132
+ const EXAMPLES = [
133
+ "turn off the kitchen light",
134
+ "bhai bedroom ka pankha high pe chala do",
135
+ "washing machine start karo",
136
+ "agar room ka temperature 42 se upar jaye to bedroom ka heater band kar do",
137
+ "if aqi goes above 250 set the living room air purifier on high",
138
+ "roz shaam 7 baje garden ki batti chala do",
139
+ "balcony wala parde khol do",
140
+ "tanki ka level 90 paar kare to motor band kar do",
141
+ "at 6:30 am open the master bedroom curtains",
142
+ "main gate khula hai kya",
143
+ "second floor bathroom ka geyser on kar do",
144
+ "jab mitti ki nami 20 se kam ho to garden ka sprinkler chalao"
145
+ ];
146
+ let history = [];
147
+ let devices = [];
148
+
149
+ const $ = id => document.getElementById(id);
150
+
151
+ function colorJSON(obj) {
152
+ const j = JSON.stringify(obj, null, 2);
153
+ return j.replace(/"([^"]+)":/g, '"<span class="k">$1</span>":')
154
+ .replace(/: "([^"]*)"/g, ': "<span class="s">$1</span>"')
155
+ .replace(/: (\d+)/g, ': <span class="n">$1</span>');
156
+ }
157
+
158
+ function render(res, text) {
159
+ $("result").style.display = "block";
160
+ const st = $("status");
161
+ st.innerHTML = "";
162
+ const tags = [];
163
+ tags.push(res.valid_json ? '<span class="tag ok">&#10003; valid JSON</span>'
164
+ : '<span class="tag bad">&#10007; invalid JSON</span>');
165
+ if (res.valid_json) {
166
+ tags.push(res.issues.length === 0 ? '<span class="tag ok">&#10003; catalog OK</span>'
167
+ : '<span class="tag warn">&#9888; check issues</span>');
168
+ if (res.command && res.command.when)
169
+ tags.push('<span class="tag dim">rule (conditional)</span>');
170
+ }
171
+ tags.push(`<span class="tag dim">${res.latency_ms} ms</span>`);
172
+ st.innerHTML = tags.join("");
173
+ $("json").innerHTML = res.valid_json ? colorJSON(res.command)
174
+ : (res.raw || "(empty)");
175
+ $("issues").innerHTML = (res.issues || [])
176
+ .map(i => `<li>${i}</li>`).join("");
177
+ history.unshift({ text, ok: res.ok });
178
+ history = history.slice(0, 8);
179
+ $("history").innerHTML = history.map((h, i) =>
180
+ `<div class="hist-item" onclick="reuse(${i})">
181
+ <span class="st" style="background:${h.ok ? 'var(--ok)' : 'var(--warn)'}"></span>
182
+ <span>${h.text.replace(/</g, "&lt;")}</span></div>`).join("");
183
+ }
184
+
185
+ function reuse(i) { $("cmd").value = history[i].text; parse(); }
186
+
187
+ async function parse() {
188
+ const text = $("cmd").value.trim();
189
+ if (!text) return;
190
+ $("go").disabled = true;
191
+ try {
192
+ const r = await fetch("/api/parse", {
193
+ method: "POST", headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({ text })
195
+ });
196
+ render(await r.json(), text);
197
+ } catch (e) {
198
+ render({ valid_json: false, raw: "server error: " + e, issues: [], latency_ms: "-" }, text);
199
+ }
200
+ $("go").disabled = false;
201
+ }
202
+
203
+ function renderDevices(q) {
204
+ q = (q || "").toLowerCase();
205
+ const hits = devices.filter(d => d.subject.includes(q)).slice(0, 60);
206
+ $("devcount").textContent =
207
+ q ? `${devices.filter(d => d.subject.includes(q)).length} match` : `${devices.length} devices`;
208
+ $("devlist").innerHTML = hits.map(d =>
209
+ `<div class="dev"><b>${d.subject}</b>
210
+ <span>${d.activity} &middot; ${d.actions.join(" / ")}</span></div>`).join("");
211
+ }
212
+
213
+ $("go").onclick = parse;
214
+ $("cmd").addEventListener("keydown", e => { if (e.key === "Enter") parse(); });
215
+ $("devsearch").addEventListener("input", e => renderDevices(e.target.value));
216
+ $("examples").innerHTML = EXAMPLES.map(e =>
217
+ `<span class="chip" onclick="$('cmd').value=this.textContent;parse()">${e}</span>`).join("");
218
+
219
+ fetch("/api/devices").then(r => r.json()).then(c => {
220
+ devices = c.catalog; renderDevices("");
221
+ });
222
+ </script>
223
+ </body>
224
+ </html>
training/train_v3.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ v3 training: plain + conditional commands, one model.
4
+ Same architecture family as train.py but 4 layers / FFN 512 (~1.1M params)
5
+ and longer context for the "when" clause. Reads train_mix.jsonl / eval_mix.jsonl
6
+ (built by prep_mix.py).
7
+
8
+ python3 train_v3.py train --seconds 3300
9
+ python3 train_v3.py eval
10
+ python3 train_v3.py export
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import math
16
+ import os
17
+ import time
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ torch.set_num_threads(2)
24
+ torch.manual_seed(108)
25
+
26
+ TRAIN_F, EVAL_F = "train_mix.jsonl", "eval_mix.jsonl"
27
+ CKPT, LOG = "ckpt3.pt", "train3_log.txt"
28
+ D, LAYERS, HEADS, FFN = 160, 4, 4, 512
29
+ MAX_LEN = 360
30
+ MAX_STEPS_HORIZON = 5_000
31
+ LR, WARMUP = 1e-3, 200
32
+
33
+
34
+ def read_rows(path):
35
+ rows = []
36
+ for line in open(path, encoding="utf-8"):
37
+ o = json.loads(line)
38
+ tgt = json.dumps(o["output"], separators=(",", ":"), ensure_ascii=False)
39
+ rows.append((o["instruction"], tgt))
40
+ return rows
41
+
42
+
43
+ def build_vocab(rows_list):
44
+ chars = set("\t\n")
45
+ for rows in rows_list:
46
+ for a, b in rows:
47
+ chars.update(a)
48
+ chars.update(b)
49
+ itos = ["<pad>"] + sorted(chars)
50
+ return itos, {c: i for i, c in enumerate(itos)}
51
+
52
+
53
+ def encode_rows(rows, stoi):
54
+ out = []
55
+ for src, tgt in rows:
56
+ ids = [stoi[c] for c in src] + [stoi["\t"]] + [stoi[c] for c in tgt] + [stoi["\n"]]
57
+ if len(ids) <= MAX_LEN:
58
+ out.append((ids, len(src) + 1))
59
+ return out
60
+
61
+
62
+ def make_batches(encoded, budget_chars=12_000, max_b=256, seed=0):
63
+ order = sorted(range(len(encoded)), key=lambda i: len(encoded[i][0]))
64
+ batches, cur = [], []
65
+ for i in order:
66
+ cur.append(i)
67
+ if len(encoded[i][0]) * len(cur) >= budget_chars or len(cur) >= max_b:
68
+ batches.append(cur)
69
+ cur = []
70
+ if cur:
71
+ batches.append(cur)
72
+ __import__("random").Random(seed).shuffle(batches)
73
+ return batches
74
+
75
+
76
+ def batch_tensors(encoded, idxs):
77
+ Lmax = max(len(encoded[i][0]) for i in idxs)
78
+ B = len(idxs)
79
+ x = torch.zeros((B, Lmax), dtype=torch.long)
80
+ labels = torch.full((B, Lmax - 1), -100, dtype=torch.long)
81
+ for r, i in enumerate(idxs):
82
+ ids, out_start = encoded[i]
83
+ x[r, :len(ids)] = torch.tensor(ids, dtype=torch.long)
84
+ for j in range(max(out_start - 1, 0), len(ids) - 1):
85
+ labels[r, j] = ids[j + 1]
86
+ return x, labels
87
+
88
+
89
+ class Block(nn.Module):
90
+ def __init__(self):
91
+ super().__init__()
92
+ self.ln1 = nn.LayerNorm(D)
93
+ self.qkv = nn.Linear(D, 3 * D)
94
+ self.proj = nn.Linear(D, D)
95
+ self.ln2 = nn.LayerNorm(D)
96
+ self.fc1 = nn.Linear(D, FFN)
97
+ self.fc2 = nn.Linear(FFN, D)
98
+
99
+ def forward(self, x, past=None):
100
+ B, T, _ = x.shape
101
+ h = self.ln1(x)
102
+ q, k, v = self.qkv(h).split(D, dim=2)
103
+ hd = D // HEADS
104
+ q = q.view(B, T, HEADS, hd).transpose(1, 2)
105
+ k = k.view(B, T, HEADS, hd).transpose(1, 2)
106
+ v = v.view(B, T, HEADS, hd).transpose(1, 2)
107
+ if past is not None:
108
+ pk, pv = past
109
+ k = torch.cat([pk, k], dim=2)
110
+ v = torch.cat([pv, v], dim=2)
111
+ att = F.scaled_dot_product_attention(q, k, v, is_causal=(past is None))
112
+ att = att.transpose(1, 2).contiguous().view(B, T, D)
113
+ x = x + self.proj(att)
114
+ return x + self.fc2(F.gelu(self.fc1(self.ln2(x)))), (k, v)
115
+
116
+
117
+ class TinyLM(nn.Module):
118
+ def __init__(self, vocab, max_len=MAX_LEN):
119
+ super().__init__()
120
+ self.tok = nn.Embedding(vocab, D)
121
+ self.pos = nn.Embedding(max_len, D)
122
+ self.blocks = nn.ModuleList([Block() for _ in range(LAYERS)])
123
+ self.lnf = nn.LayerNorm(D)
124
+ self.head = nn.Linear(D, vocab, bias=False)
125
+ self.head.weight = self.tok.weight
126
+ self.apply(self._init)
127
+
128
+ @staticmethod
129
+ def _init(m):
130
+ if isinstance(m, (nn.Linear, nn.Embedding)):
131
+ nn.init.normal_(m.weight, mean=0.0, std=0.02)
132
+ if isinstance(m, nn.Linear) and m.bias is not None:
133
+ nn.init.zeros_(m.bias)
134
+
135
+ def forward(self, x, pasts=None, past_len=0):
136
+ B, T = x.shape
137
+ pos = torch.arange(past_len, past_len + T)
138
+ h = self.tok(x) + self.pos(pos)[None, :, :]
139
+ new_pasts = []
140
+ for i, blk in enumerate(self.blocks):
141
+ h, kv = blk(h, None if pasts is None else pasts[i])
142
+ new_pasts.append(kv)
143
+ return self.head(self.lnf(h)), new_pasts
144
+
145
+ @torch.no_grad()
146
+ def generate(self, prompts, stoi, itos, max_new=170):
147
+ self.eval()
148
+ nl = stoi["\n"]
149
+ outs = [[] for _ in prompts]
150
+ by_len = {}
151
+ for i, p in enumerate(prompts):
152
+ by_len.setdefault(len(p), []).append(i)
153
+ for L, idxs in by_len.items():
154
+ for c0 in range(0, len(idxs), 64):
155
+ chunk = idxs[c0:c0 + 64]
156
+ x = torch.tensor([prompts[i] for i in chunk], dtype=torch.long)
157
+ logits, pasts = self.forward(x)
158
+ nxt = logits[:, -1].argmax(-1)
159
+ done = torch.zeros(len(chunk), dtype=torch.bool)
160
+ past_len = L
161
+ for _ in range(max_new):
162
+ for r in range(len(chunk)):
163
+ if not done[r]:
164
+ t = nxt[r].item()
165
+ if t == nl:
166
+ done[r] = True
167
+ else:
168
+ outs[chunk[r]].append(t)
169
+ if done.all():
170
+ break
171
+ logits, pasts = self.forward(nxt[:, None], pasts, past_len)
172
+ past_len += 1
173
+ nxt = logits[:, -1].argmax(-1)
174
+ return ["".join(itos[t] for t in o) for o in outs]
175
+
176
+
177
+ def lr_at(step):
178
+ if step < WARMUP:
179
+ return LR * (step + 1) / WARMUP
180
+ p = min(1.0, (step - WARMUP) / (MAX_STEPS_HORIZON - WARMUP))
181
+ return LR * (0.15 + 0.85 * 0.5 * (1 + math.cos(math.pi * p)))
182
+
183
+
184
+ def save_ckpt(model, opt, step, chars, epoch, itos):
185
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
186
+ "step": step, "chars": chars, "epoch": epoch, "itos": itos,
187
+ "config": {"D": D, "LAYERS": LAYERS, "HEADS": HEADS, "FFN": FFN,
188
+ "MAX_LEN": MAX_LEN}}, CKPT + ".tmp")
189
+ os.replace(CKPT + ".tmp", CKPT)
190
+
191
+
192
+ def cmd_train(args):
193
+ tr = read_rows(TRAIN_F)
194
+ ev = read_rows(EVAL_F)
195
+ itos, stoi = build_vocab([tr, ev])
196
+ enc = encode_rows(tr, stoi)
197
+ print(f"train rows: {len(enc)} vocab: {len(itos)}", flush=True)
198
+ model = TinyLM(len(itos))
199
+ print(f"params: {sum(p.numel() for p in model.parameters())/1e6:.2f}M", flush=True)
200
+ opt = torch.optim.AdamW(model.parameters(), lr=LR, betas=(0.9, 0.95),
201
+ weight_decay=0.01)
202
+ step = chars = epoch = 0
203
+ if os.path.exists(CKPT):
204
+ st = torch.load(CKPT, map_location="cpu", weights_only=False)
205
+ model.load_state_dict(st["model"])
206
+ opt.load_state_dict(st["opt"])
207
+ step, chars, epoch = st["step"], st["chars"], st["epoch"]
208
+ print(f"resumed at step {step}", flush=True)
209
+ logf = open(LOG, "a")
210
+ start = time.time()
211
+ deadline = start + args.seconds
212
+ ema = None
213
+ while time.time() < deadline:
214
+ for idxs in make_batches(enc, seed=epoch):
215
+ if time.time() > deadline:
216
+ break
217
+ x, labels = batch_tensors(enc, idxs)
218
+ for g in opt.param_groups:
219
+ g["lr"] = lr_at(step)
220
+ logits, _ = model(x)
221
+ loss = F.cross_entropy(logits[:, :-1].reshape(-1, logits.size(-1)),
222
+ labels.reshape(-1), ignore_index=-100)
223
+ opt.zero_grad(set_to_none=True)
224
+ loss.backward()
225
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
226
+ opt.step()
227
+ step += 1
228
+ chars += int(x.numel())
229
+ ema = loss.item() if ema is None else 0.97 * ema + 0.03 * loss.item()
230
+ if step % 20 == 0:
231
+ el = time.time() - start
232
+ msg = (f"step {step} loss {loss.item():.4f} ema {ema:.4f} "
233
+ f"lr {lr_at(step):.2e} chars {chars} elapsed {el:.0f}s "
234
+ f"sps {step/max(el, 1e-9):.2f}")
235
+ print(msg, flush=True)
236
+ logf.write(msg + "\n")
237
+ logf.flush()
238
+ if step % 300 == 0:
239
+ save_ckpt(model, opt, step, chars, epoch, itos)
240
+ epoch += 1
241
+ save_ckpt(model, opt, step, chars, epoch, itos)
242
+ print(f"DONE step {step} ema {ema} epochs {epoch}", flush=True)
243
+
244
+
245
+ def cmd_eval(args):
246
+ st = torch.load(CKPT, map_location="cpu", weights_only=False)
247
+ itos = st["itos"]
248
+ stoi = {c: i for i, c in enumerate(itos)}
249
+ model = TinyLM(len(itos))
250
+ model.load_state_dict(st["model"])
251
+ ev = read_rows(EVAL_F)
252
+ prompts, golds = [], []
253
+ for src, tgt in ev:
254
+ if any(c not in stoi for c in src) or len(src) + 1 > MAX_LEN - 170:
255
+ continue
256
+ prompts.append([stoi[c] for c in src] + [stoi["\t"]])
257
+ golds.append(tgt)
258
+ t0 = time.time()
259
+ preds = model.generate(prompts, stoi, itos)
260
+ dt = time.time() - t0
261
+ stats = {"plain": [0, 0], "conditional": [0, 0]}
262
+ valid = 0
263
+ fails = []
264
+ for p, g in zip(preds, golds):
265
+ kind = "conditional" if '"when"' in g else "plain"
266
+ stats[kind][1] += 1
267
+ try:
268
+ po = json.loads(p)
269
+ valid += 1
270
+ except Exception:
271
+ po = None
272
+ if p == g:
273
+ stats[kind][0] += 1
274
+ elif len(fails) < 12:
275
+ fails.append({"pred": p, "gold": g})
276
+ n = len(golds)
277
+ exact = stats["plain"][0] + stats["conditional"][0]
278
+ metrics = {
279
+ "eval_n": n, "exact_match": round(exact / n, 4),
280
+ "valid_json": round(valid / n, 4),
281
+ "plain_exact": round(stats["plain"][0] / max(stats["plain"][1], 1), 4),
282
+ "conditional_exact": round(stats["conditional"][0] / max(stats["conditional"][1], 1), 4),
283
+ "plain_n": stats["plain"][1], "conditional_n": stats["conditional"][1],
284
+ "decode_seconds": round(dt, 1), "step": st["step"],
285
+ }
286
+ print(json.dumps(metrics, indent=1))
287
+ json.dump({"metrics": metrics, "sample_failures": fails},
288
+ open("metrics_v3.json", "w"), indent=1)
289
+
290
+
291
+ def cmd_export(args):
292
+ import numpy as np
293
+ st = torch.load(CKPT, map_location="cpu", weights_only=False)
294
+ itos = st["itos"]
295
+ model = TinyLM(len(itos))
296
+ model.load_state_dict(st["model"])
297
+ n_params = sum(p.numel() for p in model.parameters())
298
+ torch.save({"state_dict": {k: v.half() for k, v in model.state_dict().items()},
299
+ "itos": itos, "config": st["config"], "step": st["step"]},
300
+ "model_v3_fp16.pt")
301
+ np.savez_compressed("model_v3_fp16.npz",
302
+ **{k: v.half().numpy() for k, v in model.state_dict().items()})
303
+ json.dump({"itos": itos, "config": st["config"], "params": n_params,
304
+ "step": st["step"]}, open("model_v3_config.json", "w"))
305
+ print(f"exported: {n_params} params ({n_params/1e6:.2f}M)")
306
+
307
+
308
+ if __name__ == "__main__":
309
+ ap = argparse.ArgumentParser()
310
+ sub = ap.add_subparsers(dest="cmd", required=True)
311
+ t = sub.add_parser("train")
312
+ t.add_argument("--seconds", type=int, default=3300)
313
+ sub.add_parser("eval")
314
+ sub.add_parser("export")
315
+ a = ap.parse_args()
316
+ {"train": cmd_train, "eval": cmd_eval, "export": cmd_export}[a.cmd](a)