Skip to content

Practice Hub Engine: Design & Business Logic

Status: shipped (JEE + NEET live), roadmap open past M3 LNO: L. The ranking engine behind the Practice Hub. Defines where importance, mastery and coverage come from, and which of those sources are real today. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C free, B2C Pro. B2B unaffected. Primary routes: GET /api/v1/practice-hub, GET /api/v1/practice-hub/catalog, PATCH /api/v1/practice-hub/exam-target Source of the ask: product direction, @bhanu. Refreshed 2026-07-13 against code after PRs #1277, #1305, #1314, #1317 changed the data path out from under the original draft. Related: Two Faces + Start-Practice Wizard, Practice Hub Freemium Gate, Practice Player, Study Plan (Vision)

TL;DR

priority = importance × gap, computed deterministically per request. Importance is recency-weighted past-paper yield, aggregated live from the exam bank in the knowledge_base_status database. Gap comes from the learner's own practice_attempts rows in the app DB. The app-side pyq_chapter_weightage and exam_questions copies are gone: migration 15c5ac44cf82 dropped both, and every read now goes through src/services/exam_bank/. JEE and NEET are both live. Everything else (school, subject-learner, corporate) still gets an honest, reason-tagged empty state.

The Practice Hub PM Guide explains the one rule a learner sees: study what is both heavily-tested and where you are weak. This doc is the engineering and business-logic counterpart: how that rule is computed, what data feeds it, and why the design is what it is.

Naming

This engine shipped as "Study Plan" and is now the Practice Hub: the live JEE/NEET past-paper-ranked practice surface. The name Study Plan now refers to a separate bet that generates plans and practice from NCERT or a learner's own material, see Study Plan (Vision). The two share this engine's seams (UnitProvider, MasteryProvider), which is exactly why the vision is mostly a matter of new plug-ins, not a new engine.

The promise scales across segments only if it rests on one engine, not a JEE-only special case. The engine ships with JEE live first and adds the rest as plug-ins, with no change to the engine, the API contract, or the UI.

Status

  • M1 (kwiloai_webapp PR #1204, 2026-07-02): JEE engine live with the pyq_chapter_weightage seed (82 chapters), an exam-target switcher, frontend wired.
  • M2 (2026-07-03): a mastery write-path into kwiloai-memory learner_mastery shipped alongside InteractionCounterMasteryProvider.
  • Rename (2026-07-03): the surface is "Practice Hub" everywhere user-facing. exam_family resolves only for exam-eligible learners, so non-exam learners never see JEE/NEET; they get a reason-tagged empty state.
  • Route move: the engine now serves /api/v1/practice-hub (src/api/v1/practice_hub.py). /study-plan is a different feature now: the syllabus planner (src/api/v1/study_plan.py). Any older reference to GET /api/v1/study-plan for the ranked map is stale.
  • Coverage-aware status (PR #1277): chapter status and accuracyPct are derived from practice_attempts. AttemptsDerivedMasteryProvider + AttemptsCoverageProvider (src/services/plan_engine/strategies.py:194,252) replaced InteractionCounterMasteryProvider as the wired mastery source, because learner_mastery never got populated in prod (see #1127). The counter provider is still in the file but not wired into StudyPlanService.build() (strategies.py:139-142), and src/services/mastery_counters.py now has exactly one importer: strategies.py.
  • Live bank reads (PRs #1314, #1317, 2026-07-10): the app-side ETL copies are dropped. Migration apps/backend/alembic/versions/15c5ac44cf82_drop_exam_questions_and_pyq_chapter_.py calls op.drop_table("exam_questions") and op.drop_table("pyq_chapter_weightage"). Weightage is aggregated per request from the live bank in knowledge_base_status (src/services/exam_bank/weightage.py). NEET is live, not parked: EXAM_TO_FAMILY maps NEET / AIPMT / AIIMS / KARNATAKA NEET (src/services/exam_bank/normalize.py:19-27), and EXAM_FAMILIES = {"jee", "neet"} (src/services/plan_engine/goal_resolver.py:63).

Core principle: one deterministic function, two pluggable seams

The plan is a deterministic scoring function constrained by a knowledge graph, not an agentic (LLM) system. No LLM in the request path. Pure function: data in, ranked list out, same answer every time.

priority(unit) = importance(unit | goal) × gap(learner, unit)

importance is pluggable per stream. Everything else (gap, sequencing, phases, readiness, contract, UI) is universal and built once. Adding a segment means writing one strategy and registering it — open for extension, closed for modification.

Why deterministic, not ML: it is auditable (every rank is explainable to the learner who asks "why this chapter?"), fast (single-digit ms, zero cost per load), and reproducible/testable. This is also the SOTA-appropriate choice while interaction data is thin — feature-engineered logistic and simple Bayesian models match or beat deep knowledge tracing on sparse data, and an explicit knowledge structure (à la ALEKS knowledge-space theory) makes per-learner estimation sample-efficient. Deep KT, FSRS, and neural memory models are real upgrades, but they only earn their keep at large interaction volume, so they are deferred (see Roadmap).

Architecture

A Stream = (which curriculum units are in scope) + (how their importance is computed). Two seams realize that, plus universal machinery shared by all streams.

Practice Hub engine

profile ─► GoalResolver ─► goal { stream, exam_family?, subjects[], target_date? }
              ┌─────────────────┴───────────────────────┐
              ▼                                           ▼
   UnitProvider  (seam: where units come from)   ImportanceStrategy (seam: how to weight)
   ├ BankWeightageUnitProvider  live exam bank   ├ recency-weighted yield  (live, JEE + NEET)
   ├ ConceptGraphUnits (planned) kwiloai_memory  ├ DagDepthStrategy        prereq depth  (planned)
   └ ContentUnits      (planned) Course/Lesson   └ CuratedOrderStrategy    curated order (planned)
              │                                           │
              │      MasteryProvider (universal): gap = 1 − EB_shrink(mastery from practice_attempts)
              │      CoverageProvider (universal): attempted / corpus total, drives `status`
              │                                           │
              └──────────────────► Ranker ◄──────────────┘
                       π = importance × gap, sorted by priority desc
                                PlanAssembler ─► frozen TStudyPlan  (every time, never 503)

The diagram image is older than this page

../assets/practice-hub-engine.svg still labels the mastery source learner_mastery. That table is empty in prod and is no longer what the engine reads; mastery comes from practice_attempts. The ASCII block above is the current shape. Only BankWeightageUnitProvider is wired today (src/services/plan_engine/service.py:91); the ImportanceStrategy protocol still exists (seams.py:77) but no concrete strategy class is instantiated, because importance is read straight off each Unit.recency_pct (service.py:102). The other providers named above are design intent, not code.

Component Responsibility Universal or pluggable
GoalResolver profile signals → a goal universal
UnitProvider which units are in scope, and where they come from pluggable
ImportanceStrategy importance I_c of each unit for the goal pluggable
MasteryProvider learner mastery → gap g_c universal
Ranker π = I × g, sequencing within prerequisite bands universal
PlanAssembler readiness, phases, today, subjects → frozen contract universal

Goal resolution: from what we already ask, to a stream

Kwilo already collects structured signal in two intake flows. The resolver reads them — it never fuzzy-parses free text.

Signup 3-step (persisted on the User, webapp DB):

Step Field Values
Persona signup_intent learner · trainer · parent · creator
Mode signup_mode lesson_plan · ppt · question_paper · research · exam_prep · notes
Topic signup_topic free text (advisory only)

Onboarding wizard 3-step (persisted in kwiloai-memory profiles.preferences['onboarding']):

Step Field Values
Education stage education_stage primary · secondary · pu · undergrad · postgrad · working_pro · exam_prep
Subjects subjects[] structured, varies by stage
Goal goal homework_help · understand_coursework · score_well · learn_something_new

The resolver maps these to a stream:

Signal Stream Units → importance Today
exam family resolves to jee jee live exam bank → recency-weighted yield live
exam family resolves to neet neet live exam bank → recency-weighted yield live
primary / secondary / school / pu, no exam family school (planned) ConceptGraph → DagDepth empty state, needs_exam_choice
exam_prep stage, no exam family yet exam_prep n/a until the learner picks empty state, needs_exam_choice
undergrad / postgrad subject_learner (planned) ConceptGraph → DagDepth empty state, no_exam_target
working_pro corporate (planned) CuratedOrder empty state, no_exam_target
anything unmatched generic none empty state, no_exam_target

Streams and their reason codes are const-map dispatch: _STAGE_STREAM (goal_resolver.py:165), EXAM_ELIGIBLE_STREAMS = {"school", "exam_prep"} (goal_resolver.py:161), and the reason split in StudyPlanService.build(). Exam intent is orthogonal to education stage: when an exam family resolves, it takes over the stream, so a class 5-12 learner targeting NEET is on the neet path regardless of stage (goal_resolver.py:205). A learner with no onboarding block at all gets no_preferences.

JEE vs NEET: inferred from subjects, learner-overridable

Exam-prep intent tells us the learner is preparing for an exam, but not which exam, and that decides which slice of the bank to weight. resolve_exam_family (goal_resolver.py:95) takes the most explicit signal first:

exam_family =
  1. user.exam_target_override            (explicit learner choice)
  2. onboarding_block["exam_family"]      (captured at onboarding)
  3. inferFromSubjects(onboarding subjects)

inferFromSubjects:                        (goal_resolver.py:75)
  ⊇ {physics, chemistry, mathematics}          → jee
  ⊇ {physics, chemistry, biology}              → neet
  {physics, chemistry, mathematics, biology}   → jee   (the PCM superset check fires first)
  otherwise                                    → null  (empty state, reason-tagged)

Each candidate is normalized and accepted only if it is in EXAM_FAMILIES = {"jee", "neet"}. exam_target_override is a nullable column on users. Null means "trust the inference"; a value wins over it. The learner sets it from Face A's JEE/NEET tiles, which call PATCH /api/v1/practice-hub/exam-target and get the rebuilt plan back in the same response. This keeps onboarding short while letting a NEET aspirant who listed PCMB correct the guess in one tap.

An unresolved exam family is not the same failure for everyone. School-band and exam-prep learners are exam-eligible, so they get needs_exam_choice, which is what renders the chooser. Subject learners and corporate learners get no_exam_target and are shown no exam options at all.

The "every learner gets a plan" guarantee

The resolution chain is ordered so it terminates at a provider that returns units: exam corpus where applicable, then the curriculum DAG, then the webapp's own Course / Lesson / LevelSubject content. The plan ranks on a real structural signal, never on invented numbers, and never returns 503 for a recognized learner.

Prod reality (code-verified 2026-07-13): the non-exam floor is still not seeded. The guarantee holds today for exam learners only (JEE and NEET), because the exam bank is the only populated unit source. concepts / concept_edges are still unseeded, so ConceptGraphUnits and DagDepthStrategy do not exist as code, only as named seams. Every non-exam stream gets an explicit, reason-tagged empty state (no_exam_target / needs_exam_choice / no_preferences), never a fabricated floor. A static taxonomy of chapter names was rejected: with no real importance data it would rank on invented numbers.

Mastery no longer comes from kwiloai-memory. The engine reads the learner's own practice_attempts rows in the app DB and computes accuracy per chapter in one grouped query (AttemptsDerivedMasteryProvider, strategies.py:194-249). This was a deliberate swap: learner_mastery was never populated in prod, while practice_attempts is written reliably by PracticeSessionService.submit_attempt. accuracyPct is null until the learner actually attempts questions in that chapter, and the ranking gap is not a flat 1 in the meantime. It is 1 − p_c, where p_c is the per-chapter difficulty prior computed from the bank's complexity mix (compute_chapter_prior, strategies.py:58; priors easy 0.65 / medium 0.50 / hard 0.35). So day-1 ranking already reflects real chapter difficulty. That is the documented cold-start state, not a bug.

Business logic: the scoring derivation

For each in-scope unit (chapter) c:

Symbol Meaning Source in code
I_c importance of c for the goal Unit.recency_pct / 100 from the live bank aggregation (service.py:102)
m_c raw observed accuracy of c correct / attempts over the learner's practice_attempts (strategies.py:231-247)
n_c attempts on c count of the learner's practice_attempts rows for that chapter
p_c per-chapter difficulty prior weighted from the bank complexity mix, never from the mastery row (ranker.py:102-104)
k shrinkage strength (pseudo-count) EB_K = 5 (ranker.py:17)
  1. Shrink mastery toward the chapter difficulty prior so a single lucky or unlucky attempt does not swing the rank (empirical Bayes):

    m̂_c = (n_c · m_c + k · p_c) / (n_c + k)

    Zero attempts means m_c = p_c and n_c = 0, so m̂_c = p_c exactly. Displayed accuracyPct is null (the UI shows a dash), and the ranking gap is 1 − p_c, not 1.

  2. Gap: g_c = 1 − m̂_c.

  3. Priority: π_c = I_c × g_c, sorted descending (ranker.py:129).

  4. Sequence: prerequisite banding is a named no-op seam today (_prereq_gate_seam, ranker.py:67-80). It returns the plain priority sort unchanged, and lights up only when concept_edges is seeded. today = the top-priority chapter that is not named "Miscellaneous" (assembler.py:205-208), which ranks honestly in the list but makes a poor daily focus.

  5. Assemble:

    • readiness = Σ(I_c · m̂_c over units with n_c > 0) / Σ(I_c). Units with zero attempts contribute 0 to the numerator but keep their full weight in the denominator, so a cold-start learner reads 0.0 rather than an inflated prior-driven number (assembler.py:158-168).
    • phases (diagnose → master → simulate) is a readiness-threshold state machine expressed as a const-map: diagnose < 20%, master 20-79%, simulate ≥ 80% (assembler.py:24-28).
    • per-chapter status is coverage-first, not accuracy-first: mastered requires the learner to have attempted every question in the chapter (coverage 100%) and clear an 80% accuracy gate. Perfect accuracy on a handful of questions never marks a chapter done (_chapter_status, assembler.py:61-92).
    • streakDays is currently hardcoded 0 (assembler.py:232) and dueRevisions is hardcoded 0 (assembler.py:224). The habit strip renders both. Neither is computed yet.

Importance strategies

Stream Importance source Status
JEE recency-weighted PYQ yield, 7-year half-life live
NEET same recipe, NEET family (NEET / AIPMT / AIIMS / KARNATAKA NEET raw values) live
school / subject (no exam) DAG centrality / prerequisite depth not built
CBSE / state board board blueprint weights not built
corporate competency map / curated order not built

Both live streams run the same pure function, compute_weightage (src/services/exam_bank/weightage.py:45): each question's recency weight is 0.5 ** ((anchor − year) / 7.0), where anchor is the newest known year within that exam family, and a null year is treated as FALLBACK_YEAR = 1978 so it can never outweigh a recent question. Per-chapter weights are normalized within (subject, exam_family), which is why yieldPct values are comparable inside a subject tab and not across subjects.

What is NOT verifiable from code: which subjects the NEET side of the bank actually contains. The webapp does not own the bank; it reads whatever knowledge_base_status holds. A previous note in this doc claimed "NEET is Chemistry only". That was a statement about data, not code, and it is not something this repo can confirm or refute. The runtime source of truth is GET /api/v1/practice-hub/catalog, which returns one entry per (exam_family, subject) pair that has at least one chapter with pyq_count > 0, each carrying a hasBank flag. If a NEET subject is missing from the bank, the catalog simply omits it and the UI renders an honest empty tile. Do not restate a subject-coverage claim in this doc without querying the bank first.

The DAG never computes priority on the exam path; it is intended to provide sequencing (prerequisite bands) and eligibility. For no-exam streams it would additionally become the importance source. Neither is built.

A worked example

Illustrative numbers, not a snapshot of the live bank. Say JEE Physics weights Optics at ≈ 13% and Electrostatics at ≈ 10%. A learner who has ground through Optics (m̂ = 0.9, so g = 0.1) but never touched Electrostatics gets, for the untouched chapter, m̂ = p_c. Say a medium-difficulty mix puts p_c at 0.5, so g = 0.5.

π_Optics = 0.13 × 0.1 = 0.013 versus π_Electrostatics = 0.10 × 0.5 = 0.05.

Electrostatics wins despite lower raw importance, because the learner is weak there. Note the cold-start gap is 0.5, not 1: an untouched chapter is assumed to sit at its difficulty prior, not at zero knowledge. A hard untouched chapter (p_c = 0.35, g = 0.65) therefore outranks an easy untouched one of equal weight. That is the rule, computed.

Where the data lives

This table was rebuilt from code on 2026-07-13. The previous version was built on pyq_chapter_weightage, a table that no longer exists.

Concern Home today Status
Exam weightage (JEE/NEET importance) live exam bank in the knowledge_base_status DB: exam_chapters + exam_questions, mapped read-only in src/models/exam_bank.py on their own declarative base, reached through the second engine in src/db/kb_session.py. Aggregated per request by src/services/exam_bank/weightage.py shipped (PRs #1314, #1317)
~~pyq_chapter_weightage~~ (app-side ETL copy) DROPPED. Migration 15c5ac44cf82_drop_exam_questions_and_pyq_chapter_.py drops both exam_questions and pyq_chapter_weightage from the app DB gone. Do not reference it
Mastery (the gap term) app DB practice_attempts, grouped per chapter by AttemptsDerivedMasteryProvider (strategies.py:194). Written by PracticeSessionService.submit_attempt shipped
Coverage (drives chapter status) two reads, never joined in SQL: distinct question_id count from app-DB practice_attempts, and the corpus total per chapter from the bank DB, combined in Python (AttemptsCoverageProvider, strategies.py:252-318) shipped
kwiloai-memory learner_mastery still mapped (src/services/mastery_counters.py) and still read by InteractionCounterMasteryProvider, but that provider is not wired into the engine. Empty in prod (#1127) dormant, not on the request path
Curriculum DAG (sequencing + no-exam importance) kwiloai-memory concepts / concept_edges schema exists, unseeded. _prereq_gate_seam is a no-op
Content floor (units of last resort) webapp Course / Lesson / LevelSubject B2B org tables; a B2C workspace never populates them. Not a usable floor
Chapter difficulty prior p_c derived from the bank's per-chapter complexity mix at request time (compute_chapter_prior) shipped
Onboarding signals (stream, subjects, class) kwiloai-memory profiles.preferences['onboarding'], loaded via load_onboarding_block shipped
Exam target override app DB users.exam_target_override, written by PATCH /api/v1/practice-hub/exam-target shipped
Read endpoints GET /api/v1/practice-hub, GET /api/v1/practice-hub/catalog (src/api/v1/practice_hub.py) shipped
Entitlement (Pro gate) webapp B2C plan state; enforced at the session, not the hub, by PracticeSessionService._enforce_free_tier_gate shipped

Cost of the swap. The old design deliberately kept the question corpus out of the request path by pre-aggregating it into an ~82-row app-side table. That table is gone, so the aggregation now runs per request against the bank: fetch_weightage_rows pulls one row per bank question for the family and compute_weightage folds them into per-chapter rows in Python. compute_weightage is a pure function precisely so the recency math is unit-testable without a DB, and the bank engine is a small dedicated pool (_POOL_SIZE = 2, kb_session.py:34). No caching layer sits in front of it today. If the hub gets slow, this is the first place to look, and the honest answer is that nothing in the repo currently measures it.

The bank is a foreign schema: the webapp never writes it, and the models sit on a separate declarative base so Alembic autogenerate stays blind to them. When the bank is unreachable, ExamBankUnavailableError is caught inside the provider (strategies.py:109), which returns an empty unit list; the service turns that into a corpus_pending empty-state plan rather than a 5xx. Coverage totals degrade the same way, defaulting to 0 (strategies.py:308).

API contract

Three routes, all under /api/v1/practice-hub, all with a router-level require_roles(b2c_user) dependency (src/api/v1/practice_hub.py:35-39). All three are open to free and Pro learners alike: the hub listing is browsable, and the payoff gate lives on Practice Sessions.

Route Purpose
GET /api/v1/practice-hub the ranked plan for the current learner (TStudyPlan)
GET /api/v1/practice-hub/catalog exam-family-agnostic bank catalog: every (exam_family, subject) with bank chapters, regardless of the caller's exam target. Backs the start-practice wizard
PATCH /api/v1/practice-hub/exam-target writes exam_target_override (jee, neet, or null to clear) and returns the rebuilt plan

/study-plan is a different feature

GET /api/v1/study-plan no longer serves the ranked map. That path is now the syllabus planner (src/api/v1/study_plan.py). Older docs pointing at /study-plan for the Practice Hub are stale.

Case Response
b2c learner (free or Pro), exam family resolves, bank has rows 200 valid TStudyPlan
New learner, no attempts 200 with accuracyPct: null everywhere, readinessPct: 0.0
Wrong role (not b2c_user) 403
No / invalid token 401
Invalid exam_target on the PATCH 400
No onboarding block at all 200 empty-state, reason: no_preferences
Exam-eligible learner who has not picked JEE vs NEET 200 empty-state, reason: needs_exam_choice (drives Face A's exam tiles)
Non-exam stream (subject learner, corporate, generic) 200 empty-state, reason: no_exam_target
Exam family set but the bank returns no rows for it 200 empty-state, reason: corpus_pending
Free learner practising a non-rank-1 chapter 402 upgrade-required (raised by the session route, not the hub)

The four reason codes are the EmptyPlanReason StrEnum (src/schemas/practice_hub.py:14-23) and the frontend Zod enum expects exactly these strings. An empty state is always a 200 with subjects: [] and today: null, never a fake plan and never a 503. accuracyPct, coveragePct and today are nullable and are null, not absent.

Roadmap

Milestone Deliverable Status
M1 JEE engine + seams, difficulty-prior cold-start, non-JEE empty-state shipped
M2 real mastery feeding the rank, so accuracyPct stops being null once a learner practises shipped, but not the way this doc originally planned: it landed as practice_attempts-derived mastery (PR #1277), not the kwiloai-memory learner_mastery write-path. The memory path is dormant
M2.5 live bank reads; pyq_chapter_weightage + exam_questions app-side copies dropped; NEET wired; coverage-gated chapter status; figures in the player shipped (PRs #1314, #1317, migration 15c5ac44cf82)
M3 (next) NCERT → concepts / concept_edges DAG seeder → prerequisite banding and a DagDepthStrategy, which is what makes the "every learner gets a plan" guarantee true for non-exam streams not started. Gating need: NCERT taxonomy
M4 CBSE / state-board blueprints, corporate competency not started. Gating need: blueprint data
M5 FSRS-7 + LKT, optional Elo online item-difficulty; a real streakDays and dueRevisions instead of the hardcoded zeros; grounded explanation + adaptive practice not started. Gating need: real review volume

NEET is no longer a roadmap item. It shipped in PR #1314. The old roadmap parked it at M4 behind a "corpus permitting" caveat; that caveat is resolved in code, and whether a given NEET subject has bank rows is now answered at runtime by the catalog's hasBank flag rather than by a milestone.

The highest-leverage remaining build is M3, the DAG seeder: it is the only thing standing between the engine and non-exam learners, who today see nothing but an empty state. Deep models and FSRS only earn their keep at real interaction volume (Best-LR vs deep KT on sparse data; Gervet et al. JEDM 2020), so they stay parked at M5.

Non-goals

  • An LLM in the request path. The rank is a pure function. It has to be explainable to a learner who asks "why this chapter?".
  • A fabricated content floor. A stream with no real importance signal gets an empty state with a reason code, not a plan ranked on invented numbers.
  • Owning the exam bank. The webapp reads knowledge_base_status and never writes it. Bank coverage gaps are fixed upstream in the ingestion pipeline, not here.
  • FSRS scheduling. dueRevisions returns a hardcoded 0.
  • Streaks. streakDays returns a hardcoded 0 despite the habit strip rendering it.
  • Fuzzy-mapping legacy free-string topics to chapters. Start clean with honest nulls.
  • A learner-set exam date. It defaults to a season date per family and cohort year (resolve_exam_date).

Rejected alternatives

  • Keep the app-side pyq_chapter_weightage ETL copy. Rejected and reverted. The code's own account: the table "was populated by a one-off seed + broken ETL" (weightage.py:3-4). Normalization now runs per request rather than in a batch, so a bank row that fails to normalize is skipped from this response instead of being silently absent from a stale copy (normalize.py:3-7). The cost, a per-request aggregation, is documented above and accepted.
  • A static NCERT chapter list as the non-exam floor. Rejected: without real importance data it would rank on invented numbers, which is exactly the failure mode the whole design exists to avoid. An honest empty state beats a plausible lie.
  • Rank on learner_mastery and wait for the memory ETL. Rejected after #1127: the table was empty in prod and nothing was filling it, while practice_attempts was already being written on every graded answer. Ranking on the data we actually have beat waiting for the data we designed for.
  • Gate mastered on accuracy alone. Rejected: a learner going 5-for-5 on a 60-question chapter is not done with it. Status is coverage-first, with accuracy as a second gate.