Allow field names as rule options

Change-Id: Ife4f15a09818cf6daf86b96e7c916854d0299be8
diff --git a/cmd/koralmapper/cfgparam.go b/cmd/koralmapper/cfgparam.go
index dc00da5..e5225b0 100644
--- a/cmd/koralmapper/cfgparam.go
+++ b/cmd/koralmapper/cfgparam.go
@@ -17,6 +17,8 @@
 	LayerA    string
 	FoundryB  string
 	LayerB    string
+	FieldA    string
+	FieldB    string
 }
 
 // ParseCfgParam parses the compact cfg URL parameter into a slice of
@@ -26,9 +28,12 @@
 // Format: entry (";" entry)*
 //
 //	entry = id ":" dir [ ":" foundryA ":" layerA ":" foundryB ":" layerB ]
+//	      | id ":" dir [ ":" fieldA ":" fieldB ]
 //
-// An entry has either 2 fields (all foundry/layer use defaults) or
-// 6 fields (explicit values, empty means use default).
+// Annotation entries have either 2 fields (all foundry/layer use defaults)
+// or 6 fields (explicit values, empty means use default).
+// Corpus entries have either 2 fields (all field overrides use defaults)
+// or 4 fields (explicit values, empty means use default).
 func ParseCfgParam(raw string, lists []config.MappingList) ([]CascadeEntry, error) {
 	if raw == "" {
 		return nil, nil
@@ -45,9 +50,8 @@
 	for _, part := range parts {
 		fields := strings.Split(part, ":")
 		n := len(fields)
-
-		if n != 2 && n != 6 {
-			return nil, fmt.Errorf("invalid entry %q: expected 2 or 6 colon-separated fields, got %d", part, n)
+		if n < 2 {
+			return nil, fmt.Errorf("invalid entry %q: expected at least 2 colon-separated fields, got %d", part, n)
 		}
 
 		id := fields[0]
@@ -61,30 +65,52 @@
 		if !ok {
 			return nil, fmt.Errorf("unknown mapping ID %q", id)
 		}
+		isCorpus := list.IsCorpus()
+
+		if isCorpus {
+			if n != 2 && n != 4 {
+				return nil, fmt.Errorf("invalid corpus entry %q: expected 2 or 4 colon-separated fields, got %d", part, n)
+			}
+		} else if n != 2 && n != 6 {
+			return nil, fmt.Errorf("invalid annotation entry %q: expected 2 or 6 colon-separated fields, got %d", part, n)
+		}
 
 		ce := CascadeEntry{
 			ID:        id,
 			Direction: dir,
 		}
 
-		if n == 6 {
-			ce.FoundryA = fields[2]
-			ce.LayerA = fields[3]
-			ce.FoundryB = fields[4]
-			ce.LayerB = fields[5]
-		}
+		if isCorpus {
+			if n == 4 {
+				ce.FieldA = fields[2]
+				ce.FieldB = fields[3]
+			}
+			if ce.FieldA == "" {
+				ce.FieldA = list.FieldA
+			}
+			if ce.FieldB == "" {
+				ce.FieldB = list.FieldB
+			}
+		} else {
+			if n == 6 {
+				ce.FoundryA = fields[2]
+				ce.LayerA = fields[3]
+				ce.FoundryB = fields[4]
+				ce.LayerB = fields[5]
+			}
 
-		if ce.FoundryA == "" {
-			ce.FoundryA = list.FoundryA
-		}
-		if ce.LayerA == "" {
-			ce.LayerA = list.LayerA
-		}
-		if ce.FoundryB == "" {
-			ce.FoundryB = list.FoundryB
-		}
-		if ce.LayerB == "" {
-			ce.LayerB = list.LayerB
+			if ce.FoundryA == "" {
+				ce.FoundryA = list.FoundryA
+			}
+			if ce.LayerA == "" {
+				ce.LayerA = list.LayerA
+			}
+			if ce.FoundryB == "" {
+				ce.FoundryB = list.FoundryB
+			}
+			if ce.LayerB == "" {
+				ce.LayerB = list.LayerB
+			}
 		}
 
 		result = append(result, ce)
@@ -94,9 +120,11 @@
 }
 
 // BuildCfgParam serialises a slice of CascadeEntry back to the compact
-// cfg string format. Entries with all foundry/layer fields empty use
-// the short 2-field format (id:dir). Entries with any non-empty
-// foundry/layer field use the full 6-field format.
+// cfg string format. Entries with all override fields empty use the
+// short 2-field format (id:dir). Entries with any non-empty
+// foundry/layer field use the full 6-field annotation format.
+// Entries with any non-empty fieldA/fieldB use the full 4-field
+// corpus format.
 func BuildCfgParam(entries []CascadeEntry) string {
 	if len(entries) == 0 {
 		return ""
@@ -104,8 +132,10 @@
 
 	parts := make([]string, len(entries))
 	for i, e := range entries {
-		if e.FoundryA == "" && e.LayerA == "" && e.FoundryB == "" && e.LayerB == "" {
+		if e.FoundryA == "" && e.LayerA == "" && e.FoundryB == "" && e.LayerB == "" && e.FieldA == "" && e.FieldB == "" {
 			parts[i] = e.ID + ":" + e.Direction
+		} else if e.FoundryA == "" && e.LayerA == "" && e.FoundryB == "" && e.LayerB == "" {
+			parts[i] = e.ID + ":" + e.Direction + ":" + e.FieldA + ":" + e.FieldB
 		} else {
 			parts[i] = e.ID + ":" + e.Direction + ":" + e.FoundryA + ":" + e.LayerA + ":" + e.FoundryB + ":" + e.LayerB
 		}
diff --git a/cmd/koralmapper/cfgparam_test.go b/cmd/koralmapper/cfgparam_test.go
index 398869c..2fb2e7f 100644
--- a/cmd/koralmapper/cfgparam_test.go
+++ b/cmd/koralmapper/cfgparam_test.go
@@ -28,6 +28,8 @@
 	{
 		ID:       "corpus-map",
 		Type:     "corpus",
+		FieldA:   "wikiCat",
+		FieldB:   "textClass",
 		Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
 	},
 }
@@ -91,17 +93,17 @@
 		{
 			name:    "Malformed entry with 3 fields",
 			raw:     "stts-upos:atob:extra",
-			wantErr: "invalid entry",
+			wantErr: "invalid annotation entry",
 		},
 		{
 			name:    "Malformed entry with 4 fields",
 			raw:     "stts-upos:atob:a:b",
-			wantErr: "invalid entry",
+			wantErr: "invalid annotation entry",
 		},
 		{
 			name:    "Malformed entry with 5 fields",
 			raw:     "stts-upos:atob:a:b:c",
-			wantErr: "invalid entry",
+			wantErr: "invalid annotation entry",
 		},
 		{
 			name: "Empty override fields fall back to YAML defaults",
@@ -118,13 +120,25 @@
 			},
 		},
 		{
-			name: "Corpus mapping 2-field entry has no foundry/layer defaults",
+			name: "Corpus mapping 2-field entry uses field defaults",
 			raw:  "corpus-map:atob",
 			expected: []CascadeEntry{
-				{ID: "corpus-map", Direction: "atob"},
+				{ID: "corpus-map", Direction: "atob", FieldA: "wikiCat", FieldB: "textClass"},
 			},
 		},
 		{
+			name: "Corpus mapping 4-field entry overrides defaults",
+			raw:  "corpus-map:btoa:genre:topic",
+			expected: []CascadeEntry{
+				{ID: "corpus-map", Direction: "btoa", FieldA: "genre", FieldB: "topic"},
+			},
+		},
+		{
+			name:    "Annotation mapping 4-field entry is invalid",
+			raw:     "stts-upos:atob:foo:bar",
+			wantErr: "invalid annotation entry",
+		},
+		{
 			name:    "Invalid direction",
 			raw:     "stts-upos:invalid",
 			wantErr: "invalid direction",
@@ -134,7 +148,7 @@
 			raw:  "stts-upos:atob;corpus-map:atob;other-mapper:btoa",
 			expected: []CascadeEntry{
 				{ID: "stts-upos", Direction: "atob", FoundryA: "opennlp", LayerA: "p", FoundryB: "upos", LayerB: "p"},
-				{ID: "corpus-map", Direction: "atob"},
+				{ID: "corpus-map", Direction: "atob", FieldA: "wikiCat", FieldB: "textClass"},
 				{ID: "other-mapper", Direction: "btoa", FoundryA: "stts", LayerA: "p", FoundryB: "ud", LayerB: "pos"},
 			},
 		},
@@ -175,6 +189,13 @@
 			expected: "corpus-map:atob",
 		},
 		{
+			name: "Corpus entry with field overrides uses 4-field format",
+			entries: []CascadeEntry{
+				{ID: "corpus-map", Direction: "atob", FieldA: "genre", FieldB: "topic"},
+			},
+			expected: "corpus-map:atob:genre:topic",
+		},
+		{
 			name: "Multiple entries",
 			entries: []CascadeEntry{
 				{ID: "stts-upos", Direction: "atob", FoundryA: "opennlp", LayerA: "p", FoundryB: "upos", LayerB: "p"},
@@ -218,7 +239,7 @@
 }
 
 func TestBuildAndParseCfgParamRoundTrip(t *testing.T) {
-	original := "stts-upos:atob:opennlp:p:upos:p;corpus-map:btoa"
+	original := "stts-upos:atob:opennlp:p:upos:p;corpus-map:btoa:wikiCat:textClass"
 	entries, err := ParseCfgParam(original, cfgTestLists)
 	require.NoError(t, err)
 
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 9b9b95d..3a2b318 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -448,6 +448,8 @@
 				LayerA:    entry.LayerA,
 				FoundryB:  entry.FoundryB,
 				LayerB:    entry.LayerB,
+				FieldA:    entry.FieldA,
+				FieldB:    entry.FieldB,
 			})
 		}
 
@@ -505,6 +507,8 @@
 				LayerA:    entry.LayerA,
 				FoundryB:  entry.FoundryB,
 				LayerB:    entry.LayerB,
+				FieldA:    entry.FieldA,
+				FieldB:    entry.FieldB,
 			})
 		}
 
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index ca287f0..c9b1be4 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -1748,7 +1748,7 @@
 			expectedCode: http.StatusBadRequest,
 			input:        `{"fields": []}`,
 			assertBody: func(t *testing.T, actual map[string]any) {
-				assert.Contains(t, actual["error"], "expected 2 or 6 colon-separated fields")
+				assert.Contains(t, actual["error"], "expected at least 2 colon-separated fields")
 			},
 		},
 	}
@@ -1892,6 +1892,8 @@
 			ID:          "corpus-mapper",
 			Type:        "corpus",
 			Description: "Corpus mapping",
+			FieldA:      "wikiCat",
+			FieldB:      "textClass",
 			Mappings:    []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
 		},
 	}
@@ -1951,9 +1953,16 @@
 	assert.Contains(t, htmlContent, "Annotation mapping")
 
 	// Corpus mapping entries
-	assert.Contains(t, htmlContent, "(corpus) corpus-mapper")
+	assert.Contains(t, htmlContent, "(corpus)")
+	assert.Contains(t, htmlContent, "<strong>corpus-mapper</strong>")
 	assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
 	assert.Contains(t, htmlContent, `data-type="corpus"`)
+	assert.Contains(t, htmlContent, `data-default-field-a="wikiCat"`)
+	assert.Contains(t, htmlContent, `data-default-field-b="textClass"`)
+	assert.Contains(t, htmlContent, `class="request-fieldA"`)
+	assert.Contains(t, htmlContent, `class="request-fieldB"`)
+	assert.Contains(t, htmlContent, `class="response-fieldA"`)
+	assert.Contains(t, htmlContent, `class="response-fieldB"`)
 	assert.Contains(t, htmlContent, "Corpus mapping")
 }
 
@@ -2013,11 +2022,13 @@
 	assert.Contains(t, htmlContent, `class="checkbox response-cb"`)
 }
 
-func TestConfigPageCorpusMappingHasNoFoundryInputs(t *testing.T) {
+func TestConfigPageCorpusMappingHasFieldAndDirectionInputs(t *testing.T) {
 	lists := []tmconfig.MappingList{
 		{
 			ID:       "corpus-mapper",
 			Type:     "corpus",
+			FieldA:   "genre",
+			FieldB:   "topic",
 			Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
 		},
 	}
@@ -2045,12 +2056,20 @@
 	assert.Contains(t, htmlContent, `data-type="corpus"`)
 
 	// Checkboxes present
-	assert.Contains(t, htmlContent, `class="request-cb"`)
-	assert.Contains(t, htmlContent, `class="response-cb"`)
+	assert.Contains(t, htmlContent, `class="checkbox request-cb"`)
+	assert.Contains(t, htmlContent, `class="checkbox response-cb"`)
 
-	// No foundry/layer inputs (only corpus mappings, no annotation section)
+	// No annotation foundry/layer inputs (only corpus mappings)
 	assert.NotContains(t, htmlContent, `class="request-foundryA"`)
-	assert.NotContains(t, htmlContent, `class="request-dir-arrow"`)
+	assert.NotContains(t, htmlContent, `class="request-layerA"`)
+	assert.Contains(t, htmlContent, `class="request-dir-arrow"`)
+	assert.Contains(t, htmlContent, `class="response-dir-arrow"`)
+	assert.Contains(t, htmlContent, `class="request-fieldA"`)
+	assert.Contains(t, htmlContent, `class="request-fieldB"`)
+	assert.Contains(t, htmlContent, `class="response-fieldA"`)
+	assert.Contains(t, htmlContent, `class="response-fieldB"`)
+	assert.Contains(t, htmlContent, `value="genre"`)
+	assert.Contains(t, htmlContent, `value="topic"`)
 }
 
 func TestConfigPageBackwardCompatibility(t *testing.T) {
diff --git a/cmd/koralmapper/static/config.html b/cmd/koralmapper/static/config.html
index b98254d..c258267 100644
--- a/cmd/koralmapper/static/config.html
+++ b/cmd/koralmapper/static/config.html
@@ -33,9 +33,16 @@
             </div>
             {{end}}
             {{range $.CorpusMappings}}
-            <div class="mapping" data-id="{{.ID}}" data-type="corpus" data-mode="{{$section.Mode}}">
+            <div class="mapping" data-id="{{.ID}}" data-type="corpus" data-mode="{{$section.Mode}}"
+                 data-default-field-a="{{.FieldA}}" data-default-field-b="{{.FieldB}}">
                 <div class="mapping-row">
-                    <label><input type="checkbox" class="{{$section.CheckboxClass}}" name="{{$section.CheckboxName}}"> (corpus) {{.ID}}</label>
+                    <input type="checkbox" id="check-{{.ID}}-{{$section.Mode}}" class="checkbox {{$section.CheckboxClass}}" name="{{$section.CheckboxName}}">
+                    <label for="check-{{.ID}}-{{$section.Mode}}"><span></span>(corpus) <strong>{{.ID}}</strong></label>
+                    <div class="mapping-fields {{$section.FieldsClass}}">
+                        <input type="text" class="{{$section.Mode}}-fieldA" value="{{.FieldA}}" placeholder="{{.FieldA}}" size="10">
+                        <button type="button" class="{{$section.ArrowClass}}" data-dir="{{$section.ArrowDirection}}">{{$section.ArrowLabel}}</button>
+                        <input type="text" class="{{$section.Mode}}-fieldB" value="{{.FieldB}}" placeholder="{{.FieldB}}" size="10">
+                    </div>
                 </div>
             </div>
             {{end}}
diff --git a/cmd/koralmapper/static/config.js b/cmd/koralmapper/static/config.js
index 3bf9224..9dff479 100644
--- a/cmd/koralmapper/static/config.js
+++ b/cmd/koralmapper/static/config.js
@@ -50,6 +50,8 @@
       layerA: "." + mode + "-layerA",
       foundryB: "." + mode + "-foundryB",
       layerB: "." + mode + "-layerB",
+      fieldA: "." + mode + "-fieldA",
+      fieldB: "." + mode + "-fieldB",
       dirArrow: "." + mode + "-dir-arrow"
     };
   }
@@ -68,7 +70,9 @@
       foundryA: inputValue(div, classes.foundryA),
       layerA: inputValue(div, classes.layerA),
       foundryB: inputValue(div, classes.foundryB),
-      layerB: inputValue(div, classes.layerB)
+      layerB: inputValue(div, classes.layerB),
+      fieldA: inputValue(div, classes.fieldA),
+      fieldB: inputValue(div, classes.fieldB)
     };
   }
 
@@ -82,13 +86,8 @@
         id: requestDiv.dataset.id
       };
 
-      if (requestDiv.dataset.type !== "corpus") {
-        entry.request = getModeState(requestDiv, "request");
-        entry.response = responseDiv ? getModeState(responseDiv, "response") : { enabled: false };
-      } else {
-        entry.request = { enabled: requestDiv.querySelector(".request-cb").checked };
-        entry.response = { enabled: responseDiv && responseDiv.querySelector(".response-cb").checked };
-      }
+      entry.request = getModeState(requestDiv, "request");
+      entry.response = responseDiv ? getModeState(responseDiv, "response") : { enabled: false };
 
       state.mappings.push(entry);
     }
@@ -121,6 +120,8 @@
     setInputValue(div, classes.layerA, modeState.layerA);
     setInputValue(div, classes.foundryB, modeState.foundryB);
     setInputValue(div, classes.layerB, modeState.layerB);
+    setInputValue(div, classes.fieldA, modeState.fieldA);
+    setInputValue(div, classes.fieldB, modeState.fieldB);
   }
 
   function restoreFormState(saved) {
@@ -167,13 +168,21 @@
           }
         }
       } else {
-        var requestCb = requestDiv.querySelector(".request-cb");
-        var responseCb = responseDiv ? responseDiv.querySelector(".response-cb") : null;
-        if (requestCb) {
-          requestCb.checked = !!(entry.request && entry.request.enabled);
-        }
-        if (responseCb) {
-          responseCb.checked = !!(entry.response && entry.response.enabled);
+        // Backward compatibility with old cookie schema.
+        if (entry.request && typeof entry.request === "object") {
+          restoreModeState(requestDiv, "request", entry.request);
+          if (responseDiv) {
+            restoreModeState(responseDiv, "response", entry.response);
+          }
+        } else {
+          var requestCb = requestDiv.querySelector(".request-cb");
+          var responseCb = responseDiv ? responseDiv.querySelector(".response-cb") : null;
+          if (requestCb) {
+            requestCb.checked = !!entry.request;
+          }
+          if (responseCb) {
+            responseCb.checked = !!entry.response;
+          }
         }
       }
     }
@@ -221,7 +230,13 @@
           parts.push(id + ":" + dir);
         }
       } else {
-        parts.push(id + ":" + dir);
+        var fieldA = cfgFieldValue(div, classes.fieldA, "defaultFieldA");
+        var fieldB = cfgFieldValue(div, classes.fieldB, "defaultFieldB");
+        if (fieldA || fieldB) {
+          parts.push(id + ":" + dir + ":" + fieldA + ":" + fieldB);
+        } else {
+          parts.push(id + ":" + dir);
+        }
       }
     }
 
@@ -250,18 +265,21 @@
     if (newQueryPipe === lastQueryPipe && newResponsePipe === lastResponsePipe) return;
 
     if (typeof KorAPlugin !== "undefined") {
-      if (lastQueryPipe) {
-        KorAPlugin.sendMsg({ action: "pipe", job: "del", service: lastQueryPipe });
+      if (newQueryPipe !== lastQueryPipe) {
+        if (lastQueryPipe) {
+          KorAPlugin.sendMsg({ action: "pipe", job: "del", service: lastQueryPipe });
+        }
+        if (newQueryPipe) {
+          KorAPlugin.sendMsg({ action: "pipe", job: "add", service: newQueryPipe });
+        }
       }
-      if (lastResponsePipe) {
-        KorAPlugin.sendMsg({ action: "pipe", job: "del-after", service: lastResponsePipe });
-      }
-
-      if (newQueryPipe) {
-        KorAPlugin.sendMsg({ action: "pipe", job: "add", service: newQueryPipe });
-      }
-      if (newResponsePipe) {
-        KorAPlugin.sendMsg({ action: "pipe", job: "add-after", service: newResponsePipe });
+      if (newResponsePipe !== lastResponsePipe) {
+        if (lastResponsePipe) {
+          KorAPlugin.sendMsg({ action: "pipe", job: "del-after", service: lastResponsePipe });
+        }
+        if (newResponsePipe) {
+          KorAPlugin.sendMsg({ action: "pipe", job: "add-after", service: newResponsePipe });
+        }
       }
     }
 
diff --git a/cmd/koralmapper/static/style.css b/cmd/koralmapper/static/style.css
index 8aa173d..6680a1c 100644
--- a/cmd/koralmapper/static/style.css
+++ b/cmd/koralmapper/static/style.css
@@ -1,4 +1,10 @@
-.mapping-row { display: flex; align-items: center; gap: 0.75rem; margin: 0.35rem 0; }
+.mapping-row {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin: 0.35rem 0;
+    background-color: var(--color-bg-secondary);
+}
 .mapping-fields { display: inline-flex; align-items: center; gap: 0.35rem; }
 .mapping-fields input[type="text"] { font-family: monospace; }
 .request-fields, .response-fields { flex-wrap: wrap; }
diff --git a/mapper/corpus.go b/mapper/corpus.go
index f5800ba..9aad21e 100644
--- a/mapper/corpus.go
+++ b/mapper/corpus.go
@@ -10,7 +10,7 @@
 // Rules are applied iteratively: each rule is applied to the entire tree,
 // and subsequent rules see the transformed result.
 func (m *Mapper) applyCorpusQueryMappings(mappingID string, opts MappingOptions, jsonData any) (any, error) {
-	rules := m.parsedCorpusRules[mappingID]
+	rules := m.rulesWithFieldOverrides(m.parsedCorpusRules[mappingID], opts)
 
 	jsonMap, ok := jsonData.(map[string]any)
 	if !ok {
@@ -383,7 +383,7 @@
 
 // applyCorpusResponseMappings processes fields arrays with corpus rules.
 func (m *Mapper) applyCorpusResponseMappings(mappingID string, opts MappingOptions, jsonData any) (any, error) {
-	rules := m.parsedCorpusRules[mappingID]
+	rules := m.rulesWithFieldOverrides(m.parsedCorpusRules[mappingID], opts)
 
 	jsonMap, ok := jsonData.(map[string]any)
 	if !ok {
@@ -535,3 +535,40 @@
 	return result
 }
 
+func (m *Mapper) rulesWithFieldOverrides(rules []*parser.CorpusMappingResult, opts MappingOptions) []*parser.CorpusMappingResult {
+	if opts.FieldA == "" && opts.FieldB == "" {
+		return rules
+	}
+
+	result := make([]*parser.CorpusMappingResult, len(rules))
+	for i, rule := range rules {
+		upper := rule.Upper.Clone()
+		lower := rule.Lower.Clone()
+
+		if opts.FieldA != "" {
+			applyCorpusKeyOverride(upper, opts.FieldA)
+		}
+		if opts.FieldB != "" {
+			applyCorpusKeyOverride(lower, opts.FieldB)
+		}
+
+		result[i] = &parser.CorpusMappingResult{
+			Upper: upper,
+			Lower: lower,
+		}
+	}
+
+	return result
+}
+
+func applyCorpusKeyOverride(node parser.CorpusNode, key string) {
+	switch n := node.(type) {
+	case *parser.CorpusField:
+		n.Key = key
+	case *parser.CorpusGroup:
+		for _, op := range n.Operands {
+			applyCorpusKeyOverride(op, key)
+		}
+	}
+}
+
diff --git a/mapper/corpus_test.go b/mapper/corpus_test.go
index df2ae85..c5e129c 100644
--- a/mapper/corpus_test.go
+++ b/mapper/corpus_test.go
@@ -162,6 +162,66 @@
 	assert.Equal(t, "fiction", corpus["value"])
 }
 
+func TestCorpusQueryFieldOverridesAtoB(t *testing.T) {
+	m, err := NewMapper([]config.MappingList{{
+		ID:       "corpus-test",
+		Type:     "corpus",
+		FieldA:   "textClass",
+		FieldB:   "genre",
+		Mappings: []config.MappingRule{"novel <> fiction"},
+	}})
+	require.NoError(t, err)
+
+	input := map[string]any{
+		"corpus": map[string]any{
+			"@type": "koral:doc",
+			"key":   "domain",
+			"value": "novel",
+			"match": "match:eq",
+		},
+	}
+	result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{
+		Direction: AtoB,
+		FieldA:    "domain",
+		FieldB:    "subject",
+	}, input)
+	require.NoError(t, err)
+
+	corpus := result.(map[string]any)["corpus"].(map[string]any)
+	assert.Equal(t, "subject", corpus["key"])
+	assert.Equal(t, "fiction", corpus["value"])
+}
+
+func TestCorpusQueryFieldOverridesBtoA(t *testing.T) {
+	m, err := NewMapper([]config.MappingList{{
+		ID:       "corpus-test",
+		Type:     "corpus",
+		FieldA:   "textClass",
+		FieldB:   "genre",
+		Mappings: []config.MappingRule{"novel <> fiction"},
+	}})
+	require.NoError(t, err)
+
+	input := map[string]any{
+		"corpus": map[string]any{
+			"@type": "koral:doc",
+			"key":   "subject",
+			"value": "fiction",
+			"match": "match:eq",
+		},
+	}
+	result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{
+		Direction: BtoA,
+		FieldA:    "domain",
+		FieldB:    "subject",
+	}, input)
+	require.NoError(t, err)
+
+	corpus := result.(map[string]any)["corpus"].(map[string]any)
+	assert.Equal(t, "domain", corpus["key"])
+	assert.Equal(t, "novel", corpus["value"])
+}
+
 func TestCorpusQueryFieldGroupAlias(t *testing.T) {
 	m := newCorpusMapper(t, "textClass=novel <> genre=fiction")
 
diff --git a/mapper/mapper.go b/mapper/mapper.go
index 4869c7b..ce172de 100644
--- a/mapper/mapper.go
+++ b/mapper/mapper.go
@@ -100,6 +100,8 @@
 	LayerA      string
 	FoundryB    string
 	LayerB      string
+	FieldA      string
+	FieldB      string
 	Direction   Direction
 	AddRewrites bool
 }