Skip to content

Commit b0a8f7c

Browse files
authored
Merge pull request #10 from lamalab-org/dev
Add graph-audited name assembly, retained fused parent/ion support, and expanAded role-aware nomenclature coverage
2 parents 5da8d38 + db15af9 commit b0a8f7c

140 files changed

Lines changed: 71815 additions & 654 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.git
2+
.github
3+
.venv
4+
venv
5+
__pycache__
6+
*.pyc
7+
*.pyo
8+
.pytest_cache
9+
.ruff_cache
10+
.mypy_cache
11+
.coverage
12+
htmlcov
13+
.hypothesis
14+
build
15+
dist
16+
*.egg-info
17+
examples
18+
tests
19+
docs
20+
*.md
21+
!README.md

.github/workflows/ci.yml

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
with:
2020
python-version: "3.12"
2121
- name: Install ruff
22-
run: pip install "ruff==0.6.9"
22+
run: pip install "ruff==0.15.15"
2323
- name: Ruff check
2424
run: ruff check src/bluenamer
2525
- name: Ruff format check
@@ -30,7 +30,7 @@ jobs:
3030
strategy:
3131
fail-fast: false
3232
matrix:
33-
python-version: ["3.10", "3.11", "3.12"]
33+
python-version: ["3.11", "3.12"]
3434
steps:
3535
- uses: actions/checkout@v4
3636

@@ -52,6 +52,72 @@ jobs:
5252
5353
- name: Run fast tests
5454
run: |
55-
pytest -m "not slow and not dataset" \
55+
pytest -m "not slow and not dataset and not golden" \
5656
--maxfail=20 \
5757
--durations=20
58+
59+
rdkit-compat:
60+
# Strict golden-output regression across RDKit versions. Detects
61+
# naming output that drifts with the RDKit upgrade cycle (aromaticity,
62+
# kekulisation, H-count perception). Each matrix row pins a specific
63+
# rdkit and runs only the `golden` test.
64+
runs-on: ubuntu-latest
65+
needs: [lint]
66+
strategy:
67+
fail-fast: false
68+
matrix:
69+
rdkit-version:
70+
- "2024.03.6"
71+
- "2024.09.6"
72+
- "2025.03.6"
73+
- "2025.09.5"
74+
- "2026.3.2"
75+
steps:
76+
- uses: actions/checkout@v4
77+
78+
- uses: actions/setup-python@v5
79+
with:
80+
python-version: "3.12"
81+
cache: pip
82+
83+
- name: Install package + pinned rdkit
84+
run: |
85+
python -m pip install --upgrade pip
86+
pip install -e ".[dev]"
87+
pip install --force-reinstall --no-deps "rdkit==${{ matrix.rdkit-version }}"
88+
89+
- name: Show installed versions
90+
run: |
91+
python -c "import rdkit, sys; print('python', sys.version); print('rdkit', rdkit.__version__)"
92+
93+
- name: Run golden corpus test
94+
run: pytest -m golden -v --no-header -rA
95+
96+
docker:
97+
runs-on: ubuntu-latest
98+
needs: [lint]
99+
steps:
100+
- uses: actions/checkout@v4
101+
102+
- uses: docker/setup-buildx-action@v3
103+
104+
- name: Build image
105+
uses: docker/build-push-action@v6
106+
with:
107+
context: .
108+
push: false
109+
load: true
110+
tags: bluenamer:ci
111+
112+
- name: Smoke-test the image
113+
run: |
114+
docker run -d --name bluenamer-ci -p 8000:8000 bluenamer:ci
115+
for i in $(seq 1 30); do
116+
if curl -fsS http://127.0.0.1:8000/healthz > /tmp/health.json; then
117+
cat /tmp/health.json
118+
exit 0
119+
fi
120+
sleep 2
121+
done
122+
docker logs bluenamer-ci
123+
exit 1

Dockerfile

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# ----- builder stage -----------------------------------------------------
4+
FROM python:3.12-slim AS builder
5+
6+
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
7+
PIP_NO_CACHE_DIR=1 \
8+
PYTHONDONTWRITEBYTECODE=1
9+
10+
WORKDIR /build
11+
12+
# Build deps for rdkit wheels: typically just runtime; the manylinux wheels
13+
# carry their own libs. Keep this stage thin.
14+
RUN apt-get update -qq \
15+
&& apt-get install -y --no-install-recommends build-essential \
16+
&& rm -rf /var/lib/apt/lists/*
17+
18+
COPY pyproject.toml README.md ./
19+
COPY src ./src
20+
21+
# Install into a clean prefix so we can copy a slim layer into the runtime.
22+
RUN python -m pip install --upgrade pip \
23+
&& python -m pip install --prefix=/install ".[web,opsin]"
24+
25+
26+
# ----- runtime stage -----------------------------------------------------
27+
FROM python:3.12-slim AS runtime
28+
29+
ENV PYTHONUNBUFFERED=1 \
30+
PYTHONDONTWRITEBYTECODE=1 \
31+
PATH="/usr/local/bin:${PATH}"
32+
33+
# Java runtime for OPSIN round-trip verification (py2opsin shells out to java;
34+
# OPSIN itself requires Java >=8 so the newer JRE is fine).
35+
RUN apt-get update -qq \
36+
&& apt-get install -y --no-install-recommends \
37+
default-jre-headless \
38+
curl \
39+
ca-certificates \
40+
&& rm -rf /var/lib/apt/lists/*
41+
42+
# Bring in the installed package + deps from the builder stage.
43+
COPY --from=builder /install /usr/local
44+
45+
# Non-root user.
46+
RUN useradd --create-home --shell /bin/bash bluenamer
47+
USER bluenamer
48+
WORKDIR /home/bluenamer
49+
50+
EXPOSE 8000
51+
52+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
53+
CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
54+
55+
CMD ["python", "-m", "bluenamer.web", "--host", "0.0.0.0", "--port", "8000"]

README.md

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ for step in analysis.decisions:
8585
print(step.phase, step.decision, step.reason)
8686
```
8787

88+
### Natural-language description (`describe`)
89+
90+
`bluenamer.describe(smiles)` walks the same trace and renders a
91+
deterministic, multi-paragraph explanation of how the name is built.
92+
Useful for explainability views and for generating (SMILES, name,
93+
description) training tuples:
94+
95+
```python
96+
from bluenamer import describe
97+
98+
d = describe("CC(=O)Nc1ccccc1")
99+
print(d) # multi-paragraph prose
100+
d.rules_hit # ('P-44', 'P-45', 'P-41', 'P-61', 'P-67')
101+
d.components[0] # DescribedComponent(phase='parse', text='RDKit parsed ...')
102+
```
103+
104+
Same input → same output. No LLM in the loop.
105+
88106
### CLI
89107

90108
```bash
@@ -104,7 +122,10 @@ pip install -e ".[dev]"
104122
pytest
105123

106124
# run only fast tests
107-
pytest -m "not slow and not dataset"
125+
pytest -m "not slow and not dataset and not golden"
126+
127+
# strict RDKit-version regression suite (also runs in the rdkit-compat CI job)
128+
pytest -m golden
108129

109130
# lint and format
110131
ruff check --fix src/bluenamer
@@ -113,6 +134,164 @@ ruff format src/bluenamer
113134

114135
Java is required for the OPSIN-based round-trip checks (see `py2opsin`).
115136

137+
## HTTP service (Docker)
138+
139+
The `[web]` extra ships a FastAPI app with `name`, `batch`, `describe`
140+
and `healthz` endpoints. The bundled `Dockerfile` includes a headless JRE
141+
so `verify_opsin=True` works out of the box.
142+
143+
```bash
144+
# build + run
145+
docker build -t bluenamer:local .
146+
docker run --rm -p 8000:8000 bluenamer:local
147+
148+
# or via compose
149+
docker compose -f docker/compose.yaml up --build
150+
```
151+
152+
Call the API:
153+
154+
```bash
155+
curl -X POST localhost:8000/name -H 'content-type: application/json' \
156+
-d '{"smiles":"CC(=O)Nc1ccccc1","include_trace":true,"verify_opsin":true}'
157+
158+
curl -X POST localhost:8000/batch -H 'content-type: application/json' \
159+
-d '{"smiles":["CCO","c1ccccc1","CC(=O)O"],"processes":1}'
160+
161+
curl -X POST localhost:8000/describe -H 'content-type: application/json' \
162+
-d '{"smiles":"CC(=O)Nc1ccccc1"}'
163+
```
164+
165+
OpenAPI docs are served at `http://localhost:8000/docs`.
166+
167+
## Debugging
168+
169+
Token binding metadata is currently available through the assembly decision trace when `include_trace=True`.
170+
171+
```python
172+
from blunamer import name
173+
174+
result = name("C(C1C(C(C(C(O1)O)O)O)O)O", include_trace=True)
175+
176+
print(result.name)
177+
# 6-(hydroxymethyl)oxane-2,3,4,5-tetraol
178+
```
179+
180+
To inspect token spans:
181+
182+
```python
183+
def name_token_spans(result):
184+
for step in reversed(result.decisions):
185+
if isinstance(step.data, dict) and "name_token_spans" in step.data:
186+
return step.data["name_token_spans"]
187+
return []
188+
189+
190+
for token in name_token_spans(result):
191+
print(
192+
token["text"],
193+
"atoms=", token["atoms"],
194+
"bonds=", token["bonds"],
195+
"kind=", token["token_kind"],
196+
"confidence=", token["confidence"],
197+
"source=", token["source"],
198+
)
199+
```
200+
201+
Example output:
202+
203+
```text
204+
6 atoms= [0, 11] bonds= [1, 11] kind= locant confidence= derived source= typed_rewrite
205+
hydroxymethyl atoms= [0, 11] bonds= [11] kind= prefix confidence= derived source= substituent_renderer
206+
oxane atoms= [1, 2, 3, 4, 5, 6] bonds= [2, 3, 4, 5, 6, 12] kind= parent confidence= derived source= typed_rewrite
207+
2,3,4,5 atoms= [2, 3, 4, 5, 7, 8, 9, 10] bonds= [3, 4, 5, 7, 8, 9, 10] kind= locant confidence= derived source= typed_rewrite
208+
tetraol atoms= [2, 3, 4, 5, 7, 8, 9, 10] bonds= [3, 4, 5, 7, 8, 9, 10] kind= suffix confidence= derived source= renderer_suffix
209+
```
210+
211+
The token metadata is split into `token_kind`, `ownership`, `confidence`, and `source`.
212+
213+
### `token_kind`
214+
215+
What grammar role the token plays.
216+
217+
| Value | Meaning / example |
218+
| ---------------- | --------------------------------------------------------------------------- |
219+
| `parent` | Parent skeleton token, e.g. `ethan`, `benzene`, `spiro[...]`. |
220+
| `prefix` | Prefix/substituent token, e.g. `chloro`, `methyl`, `hydroxy`. |
221+
| `suffix` | Principal suffix token, e.g. `acid`, `ol`, `one`, `nitrile`. |
222+
| `locant` | Locant token, e.g. `2`, `1,3`, `N`, `4a`. |
223+
| `charge` | Charge-bearing name part, e.g. `ium`, `oxide`, `ammonio`. |
224+
| `hydro` | Indicated hydrogen or hydro operation, e.g. `1H`, `dihydro`. |
225+
| `replacement` | Replacement prefix token, e.g. `oxa`, `aza`, `thia`. |
226+
| `unsaturation` | Unsaturation token, e.g. `en`, `yn`, `diene`. |
227+
| `modifier` | Front or suffix modifier token, e.g. stereo, hydro, or functional modifier. |
228+
| `grammar` | Pure grammar token, e.g. `di`, `bis`, or parentheses-bridging particles. |
229+
| `structural` | Structural token that does not fit a narrower kind. |
230+
| `retained_alias` | Token matched as a retained-name alias or context term. |
231+
232+
### `ownership`
233+
234+
How the token claims graph atoms.
235+
236+
| Value | Meaning / example |
237+
| ------------------------ | ------------------------------------------------------------------------------------------ |
238+
| `exact` | Token was intentionally emitted for these atoms. Best case. |
239+
| `preserves_binding` | Text matched a known binding directly after assembly. |
240+
| `preserve_all` | Rewrite preserved all previous atom ownership. |
241+
| `locanted_hydro` | Hydro token owns atoms through locants, e.g. `1H`. |
242+
| `component_locant` | Locant belongs to a component namespace, often primed spiro/fused components. |
243+
| `role_alias` | Token is an alias for a graph role. |
244+
| `retained_alias_context` | Token matched retained parent alias context. |
245+
| `stage_alias` | Token inferred from all bindings of a stage, e.g. generic suffix token. |
246+
| `grammar_scope` | Grammar token applies to nearby graph-bound terms, not its own atom. |
247+
| `multiplier_scope` | Multiplier token, e.g. `di`, scopes over repeated graph-bound terms. |
248+
| `operation_scope` | Token recovered from a named operation trace. |
249+
| `morphology_gap` | Token is a morphology bridge inside a compound token. |
250+
| `ambiguous` | Best-effort broad binding. Diagnostic only. |
251+
| `unbound` | No reliable graph binding found. Should be treated as a problem. |
252+
| `absorbed` | Token was absorbed by a rewrite into another token. |
253+
| rewrite-specific values | Values such as `retained_replace` or `merge_replaced_span`; these come from rewrite rules. |
254+
255+
### `confidence`
256+
257+
How strong the assignment is.
258+
259+
| Value | Meaning |
260+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
261+
| `exact` | Renderer emitted this token with atom metadata directly. |
262+
| `derived` | Recovered from locants, rewrite history, context, or operation trace. |
263+
| `fallback` | Best-effort binding. Useful for debugging, but not proof of correct graph naming. |
264+
265+
### `source`
266+
267+
Where the binding came from.
268+
269+
| Value | Meaning / example |
270+
| ---------------------------------- | ---------------------------------------------------------------------------- |
271+
| `renderer` | Direct renderer-emitted token. Strongest source. |
272+
| `renderer_suffix` | Principal suffix renderer emitted it. |
273+
| `functional_prefix_renderer` | Functional-prefix renderer emitted it. |
274+
| `substituent_renderer` | Substituent renderer emitted it. |
275+
| `default_binding` | Built from a broader `NameAtomBinding` when no finer token metadata existed. |
276+
| `typed_rewrite` | Came through a typed post-processing rewrite; atom metadata was propagated. |
277+
| `direct_text_match` | Token text directly matched an existing binding. |
278+
| `locant_fallback` | Locant was matched back to a binding by locant metadata. |
279+
| `charge_suffix_fallback` | Charge token inferred from charge suffix context. |
280+
| `indicated_hydrogen_fallback` | `H` or indicated hydrogen inferred from hydro metadata. |
281+
| `dihydro_locant_fallback` | Dihydro locants inferred from hydro operation metadata. |
282+
| `primed_component_locant_fallback` | Primed/component locant inferred from component scope. |
283+
| `role_alias_fallback` | Token matched a known role alias. |
284+
| `retained_alias_context` | Token matched retained-name alias context. |
285+
| `stage_fallback` | Token assigned to all bindings of a stage, e.g. parent, prefix, or suffix. |
286+
| `grammar_token` | Pure grammar token. |
287+
| `operation_trace` | Binding recovered from recorded naming operation. |
288+
| `broad_fallback` | Last-resort plausible chemical token binding. Diagnostic only. |
289+
| `unresolved` | No binding found. |
290+
| `compound_gap_bridge` | Token bridges adjacent bound tokens inside a compound word. |
291+
| `compound_gap_token` | Grammar-like token found inside a compound gap. |
292+
| `compound_gap_unresolved` | Unresolved token inside a compound gap. |
293+
| dynamic rewrite names | Any named rewrite can appear as a source if it changed the token. |
294+
116295
## License
117296

118297
MIT. See `LICENSE`.

docker/compose.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
bluenamer:
3+
build:
4+
context: ..
5+
dockerfile: Dockerfile
6+
image: bluenamer:local
7+
ports:
8+
- "8000:8000"
9+
restart: unless-stopped
10+
healthcheck:
11+
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/healthz"]
12+
interval: 30s
13+
timeout: 5s
14+
retries: 3

0 commit comments

Comments
 (0)