Skip to content

Commit 4d1900a

Browse files
committed
Reinforcement-learning fine-tuning of Gemini 3.5 Flash for Tunisian folk poetry
Tunisian folk poetry is a rare RLFT target: its metrical system is not Khalilian, so what organises a poem is a rhyme topology that differs per form and is fully decidable by a program. That yields an objective, cheap, hard-to-game structural reward on a task where supervised fine-tuning could only teach imitation of 843 specific poems. The reward is a composite of 0.7 x a deterministic structural scorer served from Cloud Run and 0.3 x a Gemini judge, kept as separate named rewards so the two curves can be watched diverging. Its central design point is that components have three different jobs and cannot share one acceptance criterion: - tunisianity discriminates (contrastive char-4gram LLR, held-out AUC 0.9999 vs the base model, 0.9875 vs MSA on an axis it was never trained on); - form and meter are saturating constraints whose AUC is below chance by construction, because the base model out-regularises the oral tradition; they are gated on a form-violation check, not on AUC; - novelty and non-repetition are guards, verified by constructing the attack they exist to stop rather than by any AUC. Every component is calibrated against the adversary that matters -- 520 base-model attempts at the real prompts -- never against line-shuffled or cross-form text, which any metric separates trivially. Includes the RLFT datasets. artifacts/dataset/{train,validation}.jsonl are the exact bytes uploaded to GCS and consumed by the tuning job, so a run is reproducible from this repository alone. No corpus text is redistributed: a prompt carries at most one opening hemistich and a hash fingerprint. The tuning client reads training metrics over REST through the experiment's backing Tensorboard, which is the only way to obtain a live training step -- the tuningJobs resource exposes no step or progress field.
0 parents  commit 4d1900a

54 files changed

Lines changed: 7337 additions & 0 deletions

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.venv/
2+
artifacts/
3+
tests/
4+
docs/
5+
scripts/
6+
.git/
7+
__pycache__/

.github/workflows/ci.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: ci
2+
on:
3+
push: { branches: [main] }
4+
pull_request:
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v4
10+
- uses: actions/setup-python@v5
11+
with: { python-version: "3.12" }
12+
- run: pip install -e ".[dev]"
13+
- run: ruff check src tests scripts
14+
- run: pytest -q

.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.venv/
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
.pytest_cache/
6+
.ruff_cache/
7+
.env
8+
9+
# macOS junk
10+
.DS_Store
11+
**/.DS_Store
12+
13+
# Everything under artifacts/ is tracked. It is the experimental record, and it
14+
# includes the RLFT datasets: artifacts/dataset/train.jsonl and validation.jsonl
15+
# are the exact bytes uploaded to GCS and consumed by the tuning job, so a run
16+
# is reproducible from the repository alone. Total is a few megabytes.

Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Reward scorer for RLFT. Runs the same installed package the tests exercise.
2+
FROM python:3.12-slim
3+
4+
ENV PYTHONDONTWRITEBYTECODE=1 \
5+
PYTHONUNBUFFERED=1 \
6+
PORT=8080
7+
8+
WORKDIR /app
9+
10+
COPY pyproject.toml README.md ./
11+
COPY src/ ./src/
12+
RUN pip install --no-cache-dir .
13+
14+
# 4 workers x 8 threads: the scorer is pure CPU and sub-millisecond, so the
15+
# limit is request concurrency, not compute. Timeout well under the tuning
16+
# service's 300 s ceiling.
17+
CMD exec gunicorn --bind :$PORT --workers 4 --threads 8 --timeout 120 \
18+
tunifolk.rewards.service:app

LICENSE

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Apache License
2+
Version 2.0, January 2004
3+
http://www.apache.org/licenses/
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.

Makefile

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# tuni-folk-gemini — reproducible pipeline
2+
PY := .venv/bin/python
3+
CORPUS ?= ../_work/build/classified.json
4+
GCS ?= gs://nemri-genai-bb-tunifolk/rlft/v1
5+
PROJECT ?= nemri-genai-bb
6+
REGION ?= us-central1
7+
JOB ?= $(shell $(PY) -c "import json;print(json.load(open('artifacts/tuning/job.json'))['name'])" 2>/dev/null)
8+
9+
.PHONY: help venv test lint watch negatives tunisianity acceptance dataset upload \
10+
deploy validate launch status clean
11+
12+
help:
13+
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n",$$1,$$2}'
14+
15+
venv: ## create the dev environment
16+
python3 -m venv .venv && .venv/bin/pip install -q -e ".[dev]"
17+
18+
test: ## run unit tests
19+
$(PY) -m pytest -q
20+
21+
lint: ## static checks
22+
.venv/bin/ruff check src tests scripts
23+
24+
# --- reward construction (run in order; each depends on the previous) --------
25+
26+
negatives: ## generate the adversary: base-model attempts at the same prompts
27+
$(PY) scripts/make_negatives.py --n 300 --n-hard 220 --n-msa 60 --corpus $(CORPUS)
28+
29+
tunisianity: ## fit + hold-out validate the contrastive discriminator
30+
$(PY) scripts/train_tunisianity.py --corpus $(CORPUS)
31+
32+
acceptance: ## GATE: reward must separate corpus from base-model output
33+
$(PY) scripts/acceptance.py --corpus $(CORPUS)
34+
35+
# --- dataset and launch -------------------------------------------------------
36+
37+
dataset: ## build train/validation JSONL
38+
$(PY) scripts/build_dataset.py --corpus $(CORPUS)
39+
40+
upload: dataset ## push the dataset to GCS
41+
gcloud storage cp artifacts/dataset/train.jsonl artifacts/dataset/validation.jsonl $(GCS)/ --project $(PROJECT)
42+
43+
deploy: ## deploy the Cloud Run reward scorer
44+
gcloud run deploy tunifolk-reward --source . --project $(PROJECT) --region $(REGION) \
45+
--no-allow-unauthenticated --memory 2Gi --cpu 1 --concurrency 20 \
46+
--min-instances 1 --max-instances 30 --timeout 120 --quiet
47+
48+
validate: ## validate the reward config against the tuning API (no job created)
49+
$(PY) scripts/launch_tuning.py --dry-run
50+
51+
launch: acceptance ## acceptance gate, validate, then create the tuning job
52+
$(PY) scripts/launch_tuning.py
53+
54+
# --- monitoring ---------------------------------------------------------------
55+
56+
status: ## report the current job's step, reward curves and checkpoints
57+
$(PY) scripts/monitor.py --once --job $(JOB)
58+
59+
watch: ## poll the current job until it terminates
60+
$(PY) scripts/monitor.py --job $(JOB)
61+
62+
clean: ## remove caches
63+
rm -rf .pytest_cache .ruff_cache
64+
find . -name __pycache__ -type d -prune -exec rm -rf {} +

README.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# tuni-folk-gemini
2+
3+
**Reinforcement-learning fine-tuning of Gemini 3.5 Flash to compose Tunisian folk poetry
4+
(الشعر الشعبي التونسي / الملحون).**
5+
6+
Tunisian folk poetry is an unusually good RLFT target. Its metrical system is *not*
7+
Khalilian — Muhammad al-Marzuqi's argument, adopted by Muhyi al-Din Khrayyef and by the
8+
editorial committee of the national corpus, is that the Tunisian dialect has `asbab` and
9+
`awtad` but no `fawasil`, so classical `buhur` cannot be applied at all:
10+
11+
> «الشعر الشعبي الحديث لا يمكن أن نطبّق عليه البحور القديمة… ولا يمكن أن نضبط موازينه
12+
> إلا بواسطة الإيقاع» — المرزوقي، *الأدب الشعبي*، ص 83
13+
14+
What *does* hold a poem together is a **rhyme topology** that differs per form and is fully
15+
decidable by a program. That gives reinforcement learning something rare: an objective,
16+
cheap, hard-to-game structural signal, on a task where supervised fine-tuning could only
17+
teach the model to imitate 843 specific poems.
18+
19+
---
20+
21+
## Approach in one page
22+
23+
RLFT is worth using when you can **score** an output better than you can **demonstrate**
24+
one. This repository is built around making that true for a creative task:
25+
26+
1. **The corpus supplies tasks, not targets.** RLFT examples carry no reference answer —
27+
only a prompt and a `references` map the reward reads. The corpus is used to derive
28+
realistic task specifications (form + `gharaḍ`, optionally a required opening
29+
hemistich) and a fingerprint of the source poem. It is never a target output.
30+
31+
2. **The reward is mostly computation, partly judgement.** A deterministic scorer
32+
(weight 0.7) measures what is decidable — rhyme topology, dialect fidelity, novelty,
33+
repetition. A Gemini judge (weight 0.3) covers only the residue that code cannot see.
34+
They stay separate so the two curves can be watched diverging.
35+
36+
3. **The adversary is the policy itself.** Every component is calibrated against 520
37+
base-model attempts at the same prompts — never line-shuffled or cross-form text, which
38+
any metric separates trivially.
39+
40+
4. **Components have three different jobs.** One discriminates, two are saturating
41+
constraints, two are guards. Holding all five to one criterion is a category error, and
42+
is the single most important thing to understand about this reward — see below.
43+
44+
5. **The task is known to be satisfiable.** Only ~41% of corpus poems labelled `malzuma`
45+
actually exhibit the `ruju'` that defines the form. Prompts are drawn only from poems
46+
that pass their own validator, so the policy is never asked for a structure most
47+
genuine examples fail.
48+
49+
6. **Offline measurement and the training reward are the same code.** The Cloud Run
50+
service imports the same installed package the unit tests exercise.
51+
52+
### Why some components score *below* chance, on purpose
53+
54+
The base model **out-regularises the tradition**. Asked for `aaaB` quatrains it emits a
55+
textbook rhyme scheme; the oral corpus is irregular, with transmission noise and uneven
56+
strophes. Measured against 520 base-model generations:
57+
58+
| Component | AUC vs base | Role | How it is verified |
59+
|---|---:|---|---|
60+
| `tunisianity` | **1.000** | discriminator | AUC ≥ 0.60 |
61+
| `form` | 0.320 | constraint | scrambling a real poem must lower it (0.923 → 0.513) |
62+
| `novelty` | 0.500 | guard | submitting the source verbatim must collapse it (→ 0.000) |
63+
| `non_repetition` | 0.397 | guard | a repeated line must collapse it (→ 0.086) |
64+
| `meter` | 0.268 | diagnostic | weight 0; reported, not optimised |
65+
66+
A monotonic structural reward would push the policy *away* from the target register, so
67+
`form` and `meter` **saturate** at per-form corpus medians: full credit for reaching the
68+
tradition's level, nothing for exceeding it. Deleting them is not the alternative — nothing
69+
would then require the requested wazn.
70+
71+
Composite held-out AUC: **0.9978** vs base darija, **0.9978** vs hard few-shot negatives,
72+
**0.9914** vs MSA (an axis it was never trained on).
73+
74+
---
75+
76+
## The four forms
77+
78+
Every structural component derives from the rhyme topology of the four `usul`:
79+
80+
| Form | Topology | Defining constraint |
81+
|---|---|---|
82+
| **القسيم** `qasim` | `a B / a B / a B …` | two parallel monorhymes, no `tali'`, no return |
83+
| **الملزومة** `malzuma` | `AA ‖ bbb A / ccc A …` | every strophe **returns** (`ruju'`) to the `tali'` rhyme — this is what *malzūma* means |
84+
| **الموقف** `mawqif` | `aaaB / cccB …` | fourth `ghusn` holds one rhyme fixed across the whole poem |
85+
| **المسدّسة** `musaddas` | `AAA ‖ bbbb AA …` | sextets whose last two `aghsan` return to the `tali'` |
86+
87+
Implemented in [`src/tunifolk/prosody/forms.py`](src/tunifolk/prosody/forms.py).
88+
89+
---
90+
91+
## Current run
92+
93+
| | |
94+
|---|---|
95+
| Job | `tunifolk-rlft-v5` · `projects/808513141082/locations/us-central1/tuningJobs/5782572726988308480` |
96+
| Base model | `gemini-3.5-flash` |
97+
| Training / validation | 843 / 174 prompts, stratified by form |
98+
| Adversary | 520 base-model darija poems (300 zero-shot + 220 few-shot) + 55 MSA |
99+
| Reward | composite: 0.7 × deterministic structural scorer + 0.3 × `gemini-3.5-flash` judge |
100+
| Total steps | 156 = (843 // 32) × 6 epochs |
101+
102+
`make status` reports the live step and reward curves. See
103+
[`docs/06-results.md`](docs/06-results.md).
104+
105+
---
106+
107+
## Layout
108+
109+
```
110+
src/tunifolk/
111+
├── prosody/ the measurable core — all of it unit-tested
112+
│ ├── normalize.py orthographic normalisation (never "corrects" dialect to MSA)
113+
│ ├── rhyme.py rawiyy extraction + rhyme agreement
114+
│ ├── forms.py the four usul as structural validators
115+
│ ├── meter.py positional length regularity (a prior, not a scansion)
116+
│ ├── tunisianity.py contrastive char-4gram LLR vs the base model
117+
│ └── novelty.py anti-plagiarism fingerprints (hash-sampled)
118+
├── rewards/
119+
│ ├── structural.py the single scoring entrypoint (tests == training)
120+
│ ├── service.py Cloud Run reward server
121+
│ └── autorater.py Gemini-as-judge config + composite builder
122+
├── data/ corpus loader, RLFT dataset builder
123+
└── tuning/client.py typed v1beta1 tuningJobs client, incl. metrics reads
124+
```
125+
126+
---
127+
128+
## Reproducing
129+
130+
```bash
131+
make venv # dev environment
132+
make test # 71 unit tests
133+
make negatives CORPUS=/path/classified.json # generate the adversary
134+
make tunisianity CORPUS=... # fit + hold-out validate the discriminator
135+
make acceptance CORPUS=... # GATE: reward must beat the base model
136+
make dataset CORPUS=... # build train/validation JSONL
137+
make upload # push to GCS
138+
make deploy # deploy the Cloud Run reward scorer
139+
make validate # validate the reward via the tuning API
140+
make launch # acceptance gate, then create the job
141+
make status # live step, curves, health, checkpoints
142+
```
143+
144+
`make validate` is not optional in spirit: `validateReinforcementTuningReward` catches a
145+
broken scorer in seconds, and >80% errored reward invocations aborts a whole run.
146+
147+
### Prerequisites
148+
149+
- A GCP project with `aiplatform`, `run`, `cloudbuild`, `artifactregistry` enabled.
150+
- `roles/run.invoker` for `service-<PROJECT_NUMBER>@gcp-sa-vertex-tune.iam.gserviceaccount.com`
151+
on the reward service — the tuning agent is the caller.
152+
- The corpus `classified.json`. **Not vendored** — see
153+
[`docs/02-dataset-design.md`](docs/02-dataset-design.md) for provenance.
154+
155+
---
156+
157+
## Documentation
158+
159+
| | |
160+
|---|---|
161+
| [01 — Background](docs/01-background.md) | the tradition, the forms, the sources |
162+
| [02 — Dataset design](docs/02-dataset-design.md) | prompt construction, stratification, provenance |
163+
| [03 — Reward design](docs/03-reward-design.md) | **each component, its role, and what the reward cannot see** |
164+
| [05 — Running a job](docs/05-running-a-tuning-job.md) | hyperparameters, monitoring, the API's sharp edges |
165+
| [06 — Results](docs/06-results.md) | protocol, pre-registered failure conditions, telemetry |
166+
167+
## What ships
168+
169+
The RLFT datasets **are** committed: `artifacts/dataset/train.jsonl` and
170+
`validation.jsonl` are the exact bytes uploaded to GCS and consumed by the tuning job, so a
171+
run is reproducible from this repository alone. So are the negatives, the fitted
172+
discriminator and the reward-gate reports.
173+
174+
No corpus text is committed. The corpus is supplied at runtime by path
175+
(`CORPUS=/path/to/classified.json`) and lives outside this repository. The prompts carry
176+
only a first hemistich and a hash fingerprint of their source poem, never the poem.
177+
178+
The one derived artifact that ships is a 300 KB contrastive n-gram model. Chaining its
179+
4-grams back into running text yields gibberish, so poems are not extractable from it;
180+
20/26 of a given line's 4-grams are present, which makes it a membership oracle rather than
181+
an extraction oracle.
182+
183+
## Licence
184+
185+
Apache-2.0 for the code in this repository.

artifacts/.gitkeep

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"train": {
3+
"path": "artifacts/dataset/train.jsonl",
4+
"examples": 843,
5+
"size_mb": 2.442,
6+
"max_example_chars": 8353,
7+
"by_form": {
8+
"qasim": 330,
9+
"musaddas": 20,
10+
"mawqif": 62,
11+
"malzuma": 431
12+
},
13+
"by_mode": {
14+
"compose": 402,
15+
"continue": 441
16+
}
17+
},
18+
"validation": {
19+
"path": "artifacts/dataset/validation.jsonl",
20+
"examples": 174,
21+
"size_mb": 0.5,
22+
"max_example_chars": 5311,
23+
"by_form": {
24+
"qasim": 72,
25+
"malzuma": 94,
26+
"mawqif": 6,
27+
"musaddas": 2
28+
},
29+
"by_mode": {
30+
"compose": 87,
31+
"continue": 87
32+
}
33+
},
34+
"config": {
35+
"min_score": 0.6,
36+
"seed": 20260731
37+
}
38+
}

0 commit comments

Comments
 (0)