This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 5f61c86003 TIKA-4895: inference results land on the one object of the 
single-obj… (#3175)
5f61c86003 is described below

commit 5f61c86003c039c57439dfcb1640d710f034fb55
Author: Tim Allison <[email protected]>
AuthorDate: Tue Sep 15 13:49:00 2026 -0400

    TIKA-4895: inference results land on the one object of the single-obj… 
(#3175)
---
 CHANGES.txt                                        | 18 ++++-
 docs/modules/ROOT/pages/configuration/index.adoc   |  9 +++
 .../pages/configuration/inference-recipes.adoc     |  5 ++
 .../ROOT/pages/configuration/inference.adoc        | 46 +++++++----
 .../pages/configuration/parsers/pdf-parser.adoc    |  3 +
 .../ROOT/pages/developers/custom-ocr-engine.adoc   |  7 ++
 .../pages/migration-to-4x/chunk-strategies.adoc    |  9 ++-
 .../apache/tika/parser/enricher/package-info.java  | 26 ++++++
 .../org/apache/tika/parser/hook/ParseHooks.java    | 39 +++++++--
 .../org/apache/tika/parser/hook/package-info.java  | 22 +++++
 .../tika/parser/inference/InferenceDispatcher.java | 52 ++++++++----
 .../tika/parser/inference/InferenceTask.java       |  4 +-
 .../tika/parser/inference/InferenceUnit.java       | 57 ++++++++++---
 .../{InferenceTask.java => package-info.java}      | 27 ++-----
 .../parser/inference/InferenceDispatcherTest.java  | 93 ++++++++++++++++++++--
 .../java/org/apache/tika/parser/mock/MockTask.java |  4 +-
 .../org/apache/tika/inference/ChunkTarget.java     | 38 +++++----
 .../java/org/apache/tika/inference/EmbedTask.java  | 15 ++--
 .../org/apache/tika/inference/ChunkTargetTest.java | 50 ++++++++++--
 .../apache/tika/pipes/core/PipesClientTest.java    | 30 +++++++
 .../server/standard/InferenceBindingsWireTest.java | 49 +++++++++++-
 21 files changed, 486 insertions(+), 117 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 31bfb2e562..44ec131a74 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * Inference results in the single-object outputs: with /tika as JSON,
+     tika-app -j, or pipes CONCATENATE, the vectors of an attachment's pages
+     and pictures now land on the one metadata object handed back, each
+     with an embedded locator naming the part, instead of being discarded
+     with the attachment's own metadata. InferenceUnit gains
+     getDestination(), where a task writes; ChunkTarget.of(unit) replaces
+     resolve(target, parent) (TIKA-4895).
+
    * A request can no longer supply openai-embedding-filter or
      jina-embedding-filter in a per-request metadata-filters list: 4.0.x
      built the filter from the request, endpoint and key included, and
@@ -154,7 +162,15 @@ Release 4.1.0 - unreleased
 
    * tika-inference and tika-vlm are experimental: classes, config keys and
      metadata output may change in minor releases without deprecation or
-     compatibility shims (TIKA-4884).
+     compatibility shims (TIKA-4884). So are the "inference" config section,
+     pdf-parser "inference", the org.apache.tika.parser.enricher, .inference
+     and .hook packages, and the tk:chunks and tk:inference-released output:
+     4.2 batches recognition per document, meant as an opt-in addition (an
+     engine that takes one image at a time keeps working unchanged), and adds
+     media inputs and document-level tasks.
+     Stable: the "engines" map, the "text-recognizers" list, and pdf-parser
+     "text"; a 4.1 spelling of those loads as an alias for at least one minor
+     release if it changes (TIKA-4895).
 
    * A missing OOXML relationship target no longer aborts the whole file:
      the threaded-comment and person lookups in xlsx and the page lookups in
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index d88a9310d5..de6d6ddb2c 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -133,6 +133,15 @@ Text recognizers are engines that produce a document's 
text from its pixels: an
 on an embedded image or a rendered PDF page, a VLM prompted to transcribe. A 
container parser
 *invokes* them on bytes it has already parsed. Two recipes cover almost every 
deployment.
 
+NOTE: This list and the `engines` map it names are stable: an entry keeps 
loading and meaning
+"this engine recognizes text for these types", and if 4.2 changes the spelling 
the 4.1 form
+loads as an alias for at least one minor release. What may change is when the 
engine is called
+and where its text lands: 4.2 batches recognition per document, so text may be 
placed after
+the walk rather than as each image is met. The Java contract behind an engine
+(`TextRecognizer`) is experimental, with batching meant as an opt-in addition 
an engine that
+takes one image at a time never sees; see
+xref:developers/custom-ocr-engine.adoc[Wiring in Your Own OCR Engine].
+
 *Choose the engine.* Configure it once under `engines` and name it here; every 
default parser
 stays loaded, and `image-parser` and `pdf-parser` call it:
 
diff --git a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc 
b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
index 777326a4a1..0d3296ddfc 100644
--- a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
@@ -23,6 +23,11 @@ pixels), xref:configuration/inference.adoc[`engines` and 
`inference`] (what is c
 and `pdf-parser.inference.input` (what a PDF releases). Every default parser 
stays loaded in
 all of them.
 
+NOTE: `engines` and `text-recognizers` are stable; `inference` (and the PDF's
+`inference.input`) is experimental in 4.1.0 and may change in a minor release 
without a
+deprecation cycle as 4.2 adds batched recognition, media inputs and 
document-level tasks. The
+recipes are correct for the release they ship with; check the release notes 
before moving.
+
 == Scanned pages only
 
 OCR a PDF page only when its extracted text is poor, and every embedded image. 
Tesseract is
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index 4eb115027d..a868057578 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -23,6 +23,14 @@ embeddings, captions, tags, classifications. (Engines that 
produce text, OCR and
 are xref:configuration/index.adoc#text-recognizers[text recognizers].) Three 
sections
 configure it, each answering one question.
 
+NOTE: Inference is experimental in 4.1.0. The `inference` section, the keys of 
an embedding
+or VLM engine's entry, the `tika-inference` and `tika-vlm` classes, and the 
chunk and vector
+output (`tk:chunks`, `tk:inference-released`) may change in a minor release 
without a
+deprecation cycle, and no compatibility shims are kept for earlier shapes; 4.2 
adds batched
+recognition, media inputs and document-level tasks. The `engines` map itself, 
and the
+`text-recognizers` list, are stable. Pin the Tika version you validated 
against and read the
+release notes before moving.
+
 == `engines`: what can be called
 
 A map of names you choose to one engine each: an endpoint or a local binary 
with its settings.
@@ -74,7 +82,9 @@ A list of bindings. Each names an engine, says what it is 
fed, and what to ask:
   document is not one. `PAGES` is one image per rendered page of a document 
whose parser
   renders pages for inference: the PDF parser, when its `inference.input` says 
so (see
   <<pages>>). `TEXT` is the extracted text of every document in the tree (see 
<<text>>).
-  (`MEDIA` follows.)
+  `MEDIA` (audio and video) is accepted by a binding but no task takes it in 
4.1: 4.2 adds
+  media chunkers (time windows for transcription, sampled frames or clips for 
an image or
+  video model) that run at the flush, and a transcribing task.
 `tasks`:: One call per task on the same engine and input. `embed` writes one 
vector chunk per
   unit into `tk:chunks`, with the page locator when the unit has a page.
 `maxChunks`:: How many units one document tree may send; for `PAGES`, how many 
pages of one
@@ -99,15 +109,22 @@ dispatcher keeps the units of the whole document tree 
until the top-level parse
 sends each binding's units to its engine in `maxBatchSize` requests: a docx 
with twelve
 pictures is one embeddings request, not twelve; if one image in a batch is 
rejected, the rest
 are retried one by one. Results land on the document a unit belongs to, as it 
appears in the
-output: a picture in the body of a docx or an email puts its vector on the 
docx or the email,
-with an `embedded` locator naming the picture, whether the docx is the file 
itself or inside
-a zip; an attachment keeps its own. A failed request marks
-the top-level document with a warning and never fails the parse.
-
-The units are held as files the dispatcher owns, bounded by each binding's 
`maxChunks` and
-`maxBytes`, and deleted when the flush ends. Vectors are visible in the 
recursive outputs
-(`/rmeta`, `-J`, pipes); a streaming `/tika` parse keeps only what lands on 
the top-level
-document.
+output. In the recursive outputs (`/rmeta`, `-J`, pipes `RMETA`), where every 
document has
+its own metadata object, a picture in the body of a docx or an email puts its 
vector on the
+docx or the email, with an `embedded` locator naming the picture, whether the 
docx is the
+file itself or inside a zip; an attachment keeps its own. In the single-object 
outputs
+(`/tika` as JSON, `-j`, pipes `CONCATENATE`) there is one object, so 
everything lands on it:
+an attachment's pages and pictures put their vectors on the top-level document 
too, each
+with an `embedded` locator whose id path and name say which part they came 
from. The chunks
+are the same either way; only the object they sit on differs. A failed request 
marks the
+top-level document with a warning and never fails the parse.
+
+The flush is the end of the top-level walk, still inside `parse()`: every byte 
has been read
+and the last document closed, the engines are called under the parse's 
timeout, and the
+units, held as files the dispatcher owns and bounded by each binding's 
`maxChunks` and
+`maxBytes`, are deleted when it ends. The `TEXT` stage alone runs after 
`parse()` returns,
+over the finished metadata list (see <<text>>). A chunker runs where its input 
is: a media
+chunker at the flush, on the file; the text chunker in the `TEXT` stage, on 
the text.
 
 [#pages]
 == Pages
@@ -190,10 +207,11 @@ narrow by document type. Use a `MARKDOWN` content handler 
for the chunker to see
 
 A document that released other inputs to inference is not embedded twice: a 
PDF parsed with
 `inference.input: ["PAGES"]` records `tk:inference-released: PAGES` and the 
TEXT stage skips
-it; list `["TEXT", "PAGES"]` to get both. Because this stage runs over the 
metadata list, it
-covers `-J`, `/rmeta` and pipes; the streaming `/tika` endpoint has no list 
and no text
-vectors. It is not a metadata filter: a per-request `metadata-filters` list 
does not touch it,
-and the per-request `inference` block below is the way to switch it off.
+it; list `["TEXT", "PAGES"]` to get both. This stage runs over the metadata 
list, so it
+covers `-J`, `/rmeta` and pipes, and `/tika` as JSON, whose list is the one 
object with the
+concatenated text; the plain-text `/tika` output carries no metadata and so no 
vectors. It is
+not a metadata filter: a per-request `metadata-filters` list does not touch 
it, and the
+per-request `inference` block below is the way to switch it off.
 
 The 4.0 `openai-embedding-filter` and `jina-embedding-filter` keep working and 
embed one
 document per request; the binding batches the tree and shares the engine with 
the other
diff --git a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
index b42dd60ad5..83f8e2ec59 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
@@ -152,6 +152,9 @@ The old boolean `extractMarkedContent` still loads: `true` 
is `TAGS`, `false` is
 
 == Pages for inference (`inference`)
 
+NOTE: Experimental in 4.1.0, with the 
xref:configuration/inference.adoc[inference bindings]
+it feeds: may change in a minor release without a deprecation cycle. `text` 
above is stable.
+
 `input`::
 What the parser releases to the 
xref:configuration/inference.adoc#pages[inference bindings]:
 `["TEXT"]` by default. Add `PAGES` to render every page for a `PAGES` binding; 
the render is
diff --git a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc 
b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
index 401966e6dc..99c3731bf1 100644
--- a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
+++ b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
@@ -26,6 +26,13 @@ Before writing code, check whether you need to. An engine 
reachable through an
 OpenAI-compatible chat completions endpoint is already covered by 
`openai-vlm-parser`; see
 xref:configuration/parsers/vlm-parsers.adoc[VLM Parsers].
 
+NOTE: The Java contract on this page is experimental in 4.1.0. Today an engine 
is called once
+per image and writes into the caller's body as it runs; 4.2 batches 
recognition per document
+and places the text after the walk. The intent is additive: an engine that 
handles one image
+at a time keeps working unchanged, called once per image into a buffer, and an 
engine whose
+service takes several images opts in by declaring a batch size and 
implementing the list call
+that 4.2 adds. The JSON that names your engine will not change.
+
 == The class
 
 [source,java]
diff --git a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
index c4e7698c19..1f0a15966e 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
@@ -145,9 +145,12 @@ The rules:
   locator it already had.
 * `"liftToParent": false` on the embedder keeps every picture's vector on the 
picture, for an
   index where each picture is a hit of its own.
-* The parent is known only in the recursive (`/rmeta`, `-J`, pipes) parse, 
where the
-  recursive wrapper exposes it to each child's parse as `ParentMetadata`; a 
component that
-  writes chunks for a child resolves the target through `ChunkTarget` 
(`tika-inference`).
+* In the recursive (`/rmeta`, `-J`, pipes `RMETA`) parse the wrapper keeps 
every document's
+  metadata, so a chunk lands on its parent's. In the single-object parse 
(`/tika` as JSON,
+  `-j`, pipes `CONCATENATE`) only the top-level document's metadata is handed 
back, so every
+  chunk lands there, with the same `embedded` locator naming the part it came 
from. A task
+  writes where the dispatcher aims it (`InferenceUnit.getDestination()`), 
through
+  `ChunkTarget` (`tika-inference`).
 
 PDF page renders follow the same rule by a different route: the PDF parser 
enriches its own
 renders and their vectors land on the PDF directly, one per page with a 
`paginated` locator,
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java
new file mode 100644
index 0000000000..d95d40213b
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * Engines a container parser invokes on bytes it has already parsed: text 
recognizers (OCR,
+ * transcription) and annotators. Experimental in 4.1: the contract is one 
image per call,
+ * written into the caller's body as it runs; 4.2 batches recognition per 
document. The intent
+ * is additive: an engine that takes one image at a time keeps working 
unchanged, and one whose
+ * service batches opts in by declaring a batch size and implementing the list 
call 4.2 adds.
+ * Until then these interfaces may change in a minor release without a 
deprecation cycle. The
+ * {@code "engines"} and {@code "text-recognizers"} configuration that names 
an engine is stable.
+ */
+package org.apache.tika.parser.enricher;
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java 
b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
index 24b1d928c5..b54bc80502 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
@@ -46,13 +46,25 @@ public final class ParseHooks implements 
TransientParseState {
 
     /** Marks a top-level parse in progress; tracks the documents being 
parsed, outermost last. */
     public static final class Run implements TransientParseState {
-        private final Deque<Metadata> open = new ArrayDeque<>();
+        private record Frame(Metadata metadata, String idPath) {
+        }
+
+        private final Deque<Frame> open = new ArrayDeque<>();
+        private int embedded;
 
-        /** Enters a document; returns the one whose parser embedded it, null 
at the top. */
+        /**
+         * Enters a document; returns the one whose parser embedded it, null 
at the top. A
+         * document the recursive wrapper did not number gets an id path in 
the wrapper's
+         * scheme, so a result written outside the wrapper still names where 
it came from.
+         */
         public Metadata enter(Metadata metadata) {
-            Metadata parent = open.peek();
-            open.push(metadata);
-            return parent;
+            Frame parent = open.peek();
+            String idPath = metadata.get(TikaCoreProperties.EMBEDDED_ID_PATH);
+            if (idPath == null && parent != null) {
+                idPath = (parent.idPath == null ? "" : parent.idPath) + "/" + 
++embedded;
+            }
+            open.push(new Frame(metadata, idPath));
+            return parent == null ? null : parent.metadata;
         }
 
         public void exit() {
@@ -61,17 +73,28 @@ public final class ParseHooks implements 
TransientParseState {
 
         /** The document whose parser is running; null between documents. */
         public Metadata current() {
-            return open.peek();
+            Frame top = open.peek();
+            return top == null ? null : top.metadata;
         }
 
         /** The document the current one is embedded in; null at the top. */
         public Metadata parent() {
-            Iterator<Metadata> it = open.iterator();
+            Iterator<Frame> it = open.iterator();
             if (!it.hasNext()) {
                 return null;
             }
             it.next();
-            return it.hasNext() ? it.next() : null;
+            return it.hasNext() ? it.next().metadata : null;
+        }
+
+        /** The id path of an open document; null for the top-level one or a 
closed one. */
+        public String idPath(Metadata metadata) {
+            for (Frame frame : open) {
+                if (frame.metadata == metadata) {
+                    return frame.idPath;
+                }
+            }
+            return null;
         }
     }
 
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java 
b/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java
new file mode 100644
index 0000000000..eb76795b20
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * The seam that sees every document a parse touches, after its own parser has 
run.
+ * Experimental in 4.1: 4.2 routes batched recognition through the same seam 
and may change
+ * these types in a minor release without a deprecation cycle.
+ */
+package org.apache.tika.parser.hook;
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
index 6db6bd0d26..5b566de340 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
@@ -43,6 +43,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.hook.ParseHook;
+import org.apache.tika.parser.hook.ParseHooks;
 
 /**
  * Matches offered units against the inference bindings and runs the tasks. 
Built once at
@@ -50,11 +51,13 @@ import org.apache.tika.parser.hook.ParseHook;
  * offered once by the auto-detect parser. Units are buffered per binding for 
the whole top-level parse and flushed at its end, so a
  * document is one request per binding, not one per unit; the buffer is 
bounded by each
  * binding's {@code maxChunks} and {@code maxBytes}, and its bytes live in 
files the
- * dispatcher owns until the flush. At the flush every unit is re-aimed at the 
metadata the
- * recursive wrapper kept for its document, since the parser's own copy is no 
longer read. A
- * request narrows the bindings through {@link InferenceSelection}. Failures 
mark the document
- * and never fail the parse. TEXT is not offered during the parse: whoever 
holds the finished
- * metadata list runs {@link #text} over it.
+ * dispatcher owns until the flush. At the flush every unit is aimed at the 
metadata that is
+ * still read: under the recursive wrapper, the copy it kept for the unit's 
document (an
+ * inline part or a page render lands on its parent); outside it, where the 
parse hands back
+ * one metadata object and the embedded documents' own are discarded, the 
top-level document.
+ * A request narrows the bindings through {@link InferenceSelection}. Failures 
mark the
+ * document and never fail the parse. TEXT is not offered during the parse: 
whoever holds the
+ * finished metadata list runs {@link #text} over it.
  *
  * @since Apache Tika 4.1
  */
@@ -177,7 +180,9 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
             if (unit == null) {
                 Path copy = state.tmp.createTempFile();
                 Files.copy(source, copy, StandardCopyOption.REPLACE_EXISTING);
-                unit = new InferenceUnit(kind, type, target, parent, copy, 
page);
+                ParseHooks.Run run = context.get(ParseHooks.Run.class);
+                unit = new InferenceUnit(kind, type, target, parent, 
idPath(target, run),
+                        parent == null ? null : idPath(parent, run), copy, 
page);
             }
             units.add(unit);
         }
@@ -197,7 +202,7 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
                 if (units == null || units.isEmpty()) {
                     continue;
                 }
-                List<InferenceUnit> kept = retarget(units, lookup);
+                List<InferenceUnit> kept = aim(units, root, lookup);
                 for (InferenceTask task : b.tasks()) {
                     try {
                         task.run(b.binding(), kept, b.engine(), context);
@@ -235,20 +240,33 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
         }
     }
 
-    /** Re-aims units at the metadata the wrapper kept; the live object where 
nothing is kept. */
-    private static List<InferenceUnit> retarget(List<InferenceUnit> units,
-                                                EmbeddedMetadataLookup lookup) 
{
-        if (lookup == null) {
-            return units;
-        }
-        List<InferenceUnit> kept = new ArrayList<>(units.size());
+    /** The wrapper's id path, or the run's for a document it did not number; 
null at the top. */
+    private static String idPath(Metadata metadata, ParseHooks.Run run) {
+        String idPath = metadata.get(TikaCoreProperties.EMBEDDED_ID_PATH);
+        return idPath != null || run == null ? idPath : run.idPath(metadata);
+    }
+
+    /**
+     * Aims units at the metadata still read. Under the wrapper that is the 
copy it kept for
+     * the unit's document and parent, the live object where nothing is kept; 
without one the
+     * parse hands back {@code root} alone, so everything lands there.
+     */
+    private static List<InferenceUnit> aim(List<InferenceUnit> units, Metadata 
root,
+                                           EmbeddedMetadataLookup lookup) {
+        List<InferenceUnit> aimed = new ArrayList<>(units.size());
         for (InferenceUnit unit : units) {
+            if (lookup == null) {
+                aimed.add(unit.aimed(unit.getTarget(), unit.getParent(), 
root));
+                continue;
+            }
             Metadata target = lookup.kept(unit.getTargetIdPath());
             Metadata parent = lookup.kept(unit.getParentIdPath());
-            kept.add(unit.retargeted(target != null ? target : 
unit.getTarget(),
-                    parent != null ? parent : unit.getParent()));
+            target = target != null ? target : unit.getTarget();
+            parent = parent != null ? parent : unit.getParent();
+            aimed.add(unit.aimed(target, parent,
+                    InferenceUnit.placed(target, parent, 
unit.getTargetIdPath())));
         }
-        return kept;
+        return aimed;
     }
 
     /**
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
index 7feb80c60a..95494c993d 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
@@ -26,7 +26,9 @@ import org.apache.tika.parser.ParseContext;
 /**
  * What a binding asks of its engine, named in the binding's {@code "tasks"}: 
one call per
  * unit for {@code embed}, all units in one call for a document-level task. 
The task owns
- * its output shape and writes it onto each unit's target (or parent).
+ * its output shape and writes it on each unit's {@link 
InferenceUnit#getDestination()
+ * destination}, never on the target directly: outside the recursive wrapper 
the target's
+ * metadata is discarded with the embedded document.
  */
 public interface InferenceTask {
 
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
index 4724bbc1a1..cbd7b0bb34 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
@@ -20,6 +20,7 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Set;
 
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
@@ -30,14 +31,22 @@ import org.apache.tika.mime.MediaType;
  * with the document they came from and, when known, its parent. Captured at 
the offer so
  * a task can run after the document has closed; the bytes live in a file the 
dispatcher
  * owns until the flush, and the id paths let the flush find the metadata the 
recursive
- * wrapper kept once the parser's own objects are no longer read.
+ * wrapper kept once the parser's own objects are no longer read. A task 
writes its result
+ * on the {@link #getDestination() destination}, which the dispatcher decides: 
the parent
+ * for an inline part or a page render, the document itself otherwise, and the 
top-level
+ * document for everything when the parse produces one metadata object.
  */
 public final class InferenceUnit {
 
+    private static final Set<String> LIFTED = Set.of(
+            TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+            TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
+
     private final InputKind kind;
     private final MediaType type;
     private final Metadata target;
     private final Metadata parent;
+    private final Metadata destination;
     private final String targetIdPath;
     private final String parentIdPath;
     private final Path path;
@@ -55,24 +64,33 @@ public final class InferenceUnit {
                          Path path, int page) throws IOException {
         this(kind, type, target, parent, 
target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
                 parent == null ? null : 
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), path,
-                null, Files.size(path), page);
+                page);
+    }
+
+    /** As above, with id paths the dispatcher resolved for documents the 
wrapper did not number. */
+    InferenceUnit(InputKind kind, MediaType type, Metadata target, Metadata 
parent,
+                  String targetIdPath, String parentIdPath, Path path, int 
page)
+            throws IOException {
+        this(kind, type, target, parent, placed(target, parent, targetIdPath), 
targetIdPath,
+                parentIdPath, path, null, Files.size(path), page);
     }
 
     /** A {@link InputKind#TEXT} unit: the target's extracted text, held in 
memory. */
     public InferenceUnit(MediaType type, Metadata target, Metadata parent, 
String text) {
-        this(InputKind.TEXT, type, target, parent,
+        this(InputKind.TEXT, type, target, parent, target,
                 target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
                 parent == null ? null : 
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), null,
                 text, text.getBytes(StandardCharsets.UTF_8).length, -1);
     }
 
     private InferenceUnit(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
-                          String targetIdPath, String parentIdPath, Path path, 
String text,
-                          long size, int page) {
+                          Metadata destination, String targetIdPath, String 
parentIdPath,
+                          Path path, String text, long size, int page) {
         this.kind = kind;
         this.type = type;
         this.target = target;
         this.parent = parent;
+        this.destination = destination;
         this.targetIdPath = targetIdPath;
         this.parentIdPath = parentIdPath;
         this.path = path;
@@ -81,10 +99,31 @@ public final class InferenceUnit {
         this.page = page;
     }
 
-    /** The same unit aimed at the metadata objects that are still read. */
-    InferenceUnit retargeted(Metadata target, Metadata parent) {
-        return new InferenceUnit(kind, type, target, parent, targetIdPath, 
parentIdPath, path,
-                text, size, page);
+    /** The same unit aimed at the metadata still read, writing on {@code 
destination}. */
+    InferenceUnit aimed(Metadata target, Metadata parent, Metadata 
destination) {
+        return new InferenceUnit(kind, type, target, parent, destination, 
targetIdPath,
+                parentIdPath, path, text, size, page);
+    }
+
+    /** Whether results for this document belong on the one it is part of, not 
on it. */
+    public static boolean lifts(Metadata target) {
+        String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
+        return type != null && LIFTED.contains(type);
+    }
+
+    /** The parent for an inline part or a page render that has one, the 
target otherwise. */
+    static Metadata placed(Metadata target, Metadata parent, String 
targetIdPath) {
+        return parent != null && targetIdPath != null && lifts(target) ? 
parent : target;
+    }
+
+    /** Where a task writes this unit's results. */
+    public Metadata getDestination() {
+        return destination;
+    }
+
+    /** Whether the results go on another document than the target, which a 
locator then names. */
+    public boolean isLifted() {
+        return destination != target;
     }
 
     /** The text of a {@link InputKind#TEXT} unit; null for the rest. */
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
similarity index 50%
copy from 
tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
copy to 
tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
index 7feb80c60a..8ea4ba997b 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
@@ -14,26 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.parser.inference;
-
-import java.io.IOException;
-import java.util.List;
-
-import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.parser.ParseContext;
-
 /**
- * What a binding asks of its engine, named in the binding's {@code "tasks"}: 
one call per
- * unit for {@code embed}, all units in one call for a document-level task. 
The task owns
- * its output shape and writes it onto each unit's target (or parent).
+ * Inference bindings: what is offered to which engine, buffered per document 
tree and run at
+ * its end. Experimental in 4.1: the {@code "inference"} configuration, the 
unit and task
+ * contracts, and the metadata they write ({@code tk:chunks}, {@code 
tk:inference-released})
+ * may change in a minor release without a deprecation cycle as 4.2 adds 
batched recognition,
+ * media inputs, and document-level tasks. The {@code "engines"} map is stable.
  */
-public interface InferenceTask {
-
-    /** Rejects an engine this task cannot use; called once at config load. */
-    default void validate(InferenceBinding binding, Engine engine) throws 
TikaConfigException {
-    }
-
-    void run(InferenceBinding binding, List<InferenceUnit> units, Engine 
engine,
-             ParseContext context) throws IOException, TikaException;
-}
+package org.apache.tika.parser.inference;
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
index 179ccb452f..c59c79ae16 100644
--- 
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
@@ -30,7 +30,9 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import org.junit.jupiter.api.Test;
@@ -62,6 +64,7 @@ public class InferenceDispatcherTest {
 
     private static final MediaType PNG = MediaType.image("png");
     private static final MediaType CONTAINER = 
MediaType.application("x-test-container");
+    private static final MediaType INNER = MediaType.image("x-test-inner");
 
     static final class RecordingEngine implements Engine {
     }
@@ -105,11 +108,25 @@ public class InferenceDispatcherTest {
         }
     }
 
-    /** A container whose one child is a PNG, parsed through the embedded 
extractor. */
+    /**
+     * A container parsed through the embedded extractor: "CONTAINER" holds 
one inline PNG;
+     * "NESTED" holds a "CONTAINER" of the {@link #INNER} type as an 
attachment, like a zip
+     * holding a docx with a picture. INNER is an image type so the attachment 
is offered too.
+     */
     static final class ContainerParser implements Parser {
+        private final MediaType type;
+
+        ContainerParser() {
+            this(CONTAINER);
+        }
+
+        ContainerParser(MediaType type) {
+            this.type = type;
+        }
+
         @Override
         public Set<MediaType> getSupportedTypes(ParseContext context) {
-            return Collections.singleton(CONTAINER);
+            return Collections.singleton(type);
         }
 
         @Override
@@ -117,11 +134,13 @@ public class InferenceDispatcherTest {
                           ParseContext context) throws IOException, 
SAXException, TikaException {
             XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
             xhtml.startDocument();
+            boolean nested = "NESTED".equals(new String(tis.readAllBytes(), 
UTF_8));
             Metadata child = new Metadata();
-            child.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
-            child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
-            child.set(TikaCoreProperties.RESOURCE_NAME_KEY, "image1.png");
-            try (TikaInputStream childStream = 
TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            child.set(HttpHeaders.CONTENT_TYPE, nested ? INNER.toString() : 
PNG.toString());
+            child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, nested ? 
"ATTACHMENT" : "INLINE");
+            child.set(TikaCoreProperties.RESOURCE_NAME_KEY, nested ? 
"inner.docx" : "image1.png");
+            byte[] bytes = (nested ? "CONTAINER" : 
"PNG-BYTES").getBytes(UTF_8);
+            try (TikaInputStream childStream = TikaInputStream.get(bytes)) {
                 EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context)
                         .parseEmbedded(childStream, xhtml, child, context, 
false);
             }
@@ -257,6 +276,7 @@ public class InferenceDispatcherTest {
         InferenceUnit unit = task.runs.get(0).get(0);
         assertSame(handler.kept, unit.getParent(), "the parent the wrapper 
kept, not the live one");
         assertSame(liveChild, unit.getTarget(), "nothing kept for the child: 
the live object");
+        assertSame(liveChild, unit.getDestination(), "untyped: not lifted");
         assertEquals("/1/2", unit.getTargetIdPath());
     }
 
@@ -304,11 +324,69 @@ public class InferenceDispatcherTest {
             parser.parse(tis, new DefaultHandler(), root, context);
         }
         assertEquals(1, task.runs.size());
-        assertSame(root, task.runs.get(0).get(0).getParent(),
+        InferenceUnit unit = task.runs.get(0).get(0);
+        assertSame(root, unit.getParent(),
                 "the parent is named in every mode, not only under the 
wrapper");
+        assertSame(root, unit.getDestination(), "one output object: results 
land on it");
+        assertEquals("/1", unit.getTargetIdPath(), "numbered as the wrapper 
would have");
+        assertNull(unit.getParentIdPath());
         assertNull(context.get(ParseHooks.class), "seeding is undone at the 
top level");
     }
 
+    /** Outside the wrapper the embedded documents' metadata is discarded: 
every unit lands on the root. */
+    @Test
+    public void testDeepChildOutsideTheWrapperLandsOnTheRoot() throws 
Exception {
+        RecordingTask task = new RecordingTask();
+        AutoDetectParser parser = hooked(pngDispatcher(task), new 
ContainerParser(),
+                new ContainerParser(INNER), new TypedParser(PNG));
+        Metadata root = new Metadata();
+        root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+        try (TikaInputStream tis = 
TikaInputStream.get("NESTED".getBytes(UTF_8))) {
+            parser.parse(tis, new DefaultHandler(), root, new ParseContext());
+        }
+        assertEquals(2, task.runs.get(0).size(), "the picture and the 
attachment");
+        InferenceUnit unit = task.runs.get(0).get(0);
+        assertEquals("image1.png", 
unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("inner.docx", 
unit.getParent().get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertSame(root, unit.getDestination());
+        assertTrue(unit.isLifted());
+        assertEquals("/1/2", unit.getTargetIdPath());
+        assertEquals("/1", unit.getParentIdPath());
+        InferenceUnit attachment = task.runs.get(0).get(1);
+        assertSame(root, attachment.getDestination(), "an attachment too: 
there is nowhere else");
+        assertTrue(attachment.isLifted());
+        assertEquals("/1", attachment.getTargetIdPath());
+    }
+
+    /** Under the wrapper an attachment keeps its own results, on the copy the 
wrapper kept. */
+    @Test
+    public void testAttachmentUnderTheWrapperKeepsItsOwn() throws Exception {
+        RecordingTask task = new RecordingTask();
+        AutoDetectParser parser = hooked(pngDispatcher(task), new 
ContainerParser(),
+                new ContainerParser(INNER), new TypedParser(PNG));
+        RecursiveParserWrapper wrapper = new RecursiveParserWrapper(parser);
+        Metadata root = new Metadata();
+        root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+        RecursiveParserWrapperHandler handler = new 
RecursiveParserWrapperHandler(
+                new 
BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
+        try (TikaInputStream tis = 
TikaInputStream.get("NESTED".getBytes(UTF_8))) {
+            wrapper.parse(tis, handler, root, new ParseContext());
+        }
+        assertEquals(3, handler.getMetadataList().size());
+        Metadata keptDocx = handler.getEmbeddedMetadata("/1");
+        Map<String, InferenceUnit> byName = new HashMap<>();
+        for (InferenceUnit unit : task.runs.get(0)) {
+            
byName.put(unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY), unit);
+        }
+        InferenceUnit picture = byName.get("image1.png");
+        InferenceUnit attachment = byName.get("inner.docx");
+        assertSame(keptDocx, attachment.getDestination(), "the attachment's 
kept copy");
+        assertFalse(attachment.isLifted());
+        assertSame(keptDocx, picture.getDestination(), "lifted onto the docx 
it is part of");
+        assertTrue(picture.isLifted());
+        assertEquals("/1/2", picture.getTargetIdPath());
+    }
+
     @Test
     public void testOffersAndFlushesAtTopLevel() throws Exception {
         RecordingTask task = new RecordingTask();
@@ -344,6 +422,7 @@ public class InferenceDispatcherTest {
         assertArrayEquals("PNG-BYTES".getBytes(UTF_8), 
task.bytes.get(0).get(0));
         assertEquals("image1.png", 
unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY));
         assertSame(root, unit.getParent());
+        assertSame(root, unit.getDestination(), "inline: lifted onto its 
parent");
         assertEquals(2, handler.getMetadataList().size());
     }
 
diff --git a/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java 
b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
index ea7266c946..b5d920faa7 100644
--- a/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
+++ b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
@@ -28,7 +28,7 @@ import org.apache.tika.parser.inference.InferenceUnit;
 
 /**
  * Fixture for {@code "inference"} tasks: nameable as {@code mock-task}, marks 
each unit's
- * parent (or the unit itself) with the binding id and the number of units it 
saw at once.
+ * destination with the binding id and the number of units it saw at once.
  */
 @TikaComponent(name = "mock-task", spi = false)
 public class MockTask implements InferenceTask {
@@ -40,7 +40,7 @@ public class MockTask implements InferenceTask {
     public void run(InferenceBinding binding, List<InferenceUnit> units, 
Engine engine,
                     ParseContext context) {
         for (InferenceUnit unit : units) {
-            Metadata onto = unit.getParent() != null ? unit.getParent() : 
unit.getTarget();
+            Metadata onto = unit.getDestination();
             onto.set(MARKER_KEY, binding.getId());
             onto.set(UNITS_KEY, Integer.toString(units.size()));
         }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
index 53592873cc..a279552396 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
@@ -18,26 +18,22 @@ package org.apache.tika.inference;
 
 import java.io.IOException;
 import java.util.List;
-import java.util.Set;
 
 import org.apache.tika.extractor.ParentMetadata;
 import org.apache.tika.inference.locator.EmbeddedLocator;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.InferenceUnit;
 
 /**
- * Where the chunks produced for a document land. A picture in the body of a 
docx or an
- * email, or a rendering of a page, is part of its parent, so its chunks go 
onto the parent
- * with an {@link EmbeddedLocator} naming the child. An attachment, a 
top-level file, or a
- * child parsed outside the recursive wrapper keeps its own.
+ * Where the chunks produced for a unit land and how they name where they came 
from. The
+ * dispatcher decides the destination ({@link 
InferenceUnit#getDestination()}); when that is
+ * not the unit's own document, an {@link EmbeddedLocator} names the child the 
chunks came
+ * from. The in-parse form is for a parser that writes during its own 
document's parse.
  */
 public final class ChunkTarget {
 
-    private static final Set<String> LIFTED = Set.of(
-            TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
-            TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
-
     private final Metadata metadata;
     private final EmbeddedLocator locator;
 
@@ -46,19 +42,27 @@ public final class ChunkTarget {
         this.locator = locator;
     }
 
-    public static ChunkTarget resolve(Metadata target, ParseContext context) {
-        ParentMetadata parent = context.get(ParentMetadata.class);
-        return resolve(target, parent == null ? null : parent.getMetadata());
+    /** The unit's destination, with a locator naming the unit's document when 
lifted off it. */
+    public static ChunkTarget of(InferenceUnit unit) {
+        if (!unit.isLifted()) {
+            return new ChunkTarget(unit.getDestination(), null);
+        }
+        return new ChunkTarget(unit.getDestination(), new 
EmbeddedLocator(unit.getTargetIdPath(),
+                unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY)));
     }
 
-    /** As above, with the parent captured earlier (the dispatcher runs after 
the walk). */
-    public static ChunkTarget resolve(Metadata target, Metadata parent) {
+    /**
+     * During a parse: the parent for an inline part or a page render the 
wrapper numbered,
+     * the document itself otherwise.
+     */
+    public static ChunkTarget resolve(Metadata target, ParseContext context) {
+        ParentMetadata parent = context.get(ParentMetadata.class);
         String idPath = target.get(TikaCoreProperties.EMBEDDED_ID_PATH);
-        String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
-        if (parent == null || idPath == null || type == null || 
!LIFTED.contains(type)) {
+        if (parent == null || parent.getMetadata() == null || idPath == null
+                || !InferenceUnit.lifts(target)) {
             return new ChunkTarget(target, null);
         }
-        return new ChunkTarget(parent,
+        return new ChunkTarget(parent.getMetadata(),
                 new EmbeddedLocator(idPath, 
target.get(TikaCoreProperties.RESOURCE_NAME_KEY)));
     }
 
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
index 58f96a5c86..63fbe001b6 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
@@ -38,11 +38,11 @@ import org.apache.tika.parser.inference.InferenceUnit;
 import org.apache.tika.parser.inference.InputKind;
 
 /**
- * The {@code embed} task. Images and pages: one vector chunk per unit, 
written where the
- * unit's chunks belong (its parent for an inline picture or a page render, 
itself otherwise),
- * with the page locator when the unit is a page or has one. Text: the unit's 
text is chunked
- * and every chunk gets a vector, on the unit's own document. Requests are 
filled to the
- * engine's batch size; every chunk names the binding as its producer.
+ * The {@code embed} task. Images and pages: one vector chunk per unit, 
written on the
+ * unit's destination with the page locator when the unit is a page or has 
one. Text: the
+ * unit's text is chunked and every chunk gets a vector, on the unit's own 
document.
+ * Requests are filled to the engine's batch size; every chunk names the 
binding as its
+ * producer.
  */
 @TikaComponent(name = "embed", spi = false)
 public class EmbedTask implements InferenceTask {
@@ -114,7 +114,7 @@ public class EmbedTask implements InferenceTask {
             for (int[] span : spans) {
                 Chunk chunk = new Chunk(text.substring(span[0], span[1]), 
span[0], span[1]);
                 chunk.setProducer(binding.getId());
-                owned.add(new Owned(chunk, unit.getTarget()));
+                owned.add(new Owned(chunk, unit.getDestination()));
             }
         }
         if (binding.getMaxChunks() >= 0 && owned.size() > 
binding.getMaxChunks()) {
@@ -206,8 +206,7 @@ public class EmbedTask implements InferenceTask {
             Chunk chunk = new Chunk(null, locators);
             chunk.setVector(vectors.get(i));
             chunk.setProducer(binding.getId());
-            ChunkTarget.resolve(unit.getTarget(), unit.getParent())
-                    .write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
+            ChunkTarget.of(unit).write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
         }
     }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
index fbd7df4ca0..5cebc511d3 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
@@ -20,18 +20,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.List;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.tika.extractor.ParentMetadata;
 import org.apache.tika.inference.locator.Locators;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.InferenceUnit;
+import org.apache.tika.parser.inference.InputKind;
 
 public class ChunkTargetTest {
 
+    @TempDir
+    Path tmp;
+
     private static Metadata child(String type, String idPath, String name) {
         Metadata m = new Metadata();
         if (type != null) {
@@ -50,8 +60,33 @@ public class ChunkTargetTest {
         return context;
     }
 
+    private InferenceUnit unit(Metadata target, Metadata parent) throws 
IOException {
+        Path file = Files.createTempFile(tmp, "unit", ".png");
+        return new InferenceUnit(InputKind.IMAGES, MediaType.image("png"), 
target, parent, file);
+    }
+
+    /** The unit's destination is the dispatcher's call; a lifted unit names 
its document. */
+    @Test
+    public void testOfFollowsTheUnit() throws Exception {
+        Metadata parent = new Metadata();
+        for (String type : List.of("INLINE", "RENDERING")) {
+            ChunkTarget target = ChunkTarget.of(unit(child(type, "/1", 
"image1.png"), parent));
+            assertSame(parent, target.getMetadata());
+            assertEquals("/1", target.getLocator().getIdPath());
+            assertEquals("image1.png", target.getLocator().getName());
+        }
+        Metadata attachment = child("ATTACHMENT", "/2", "report.pdf");
+        ChunkTarget own = ChunkTarget.of(unit(attachment, parent));
+        assertSame(attachment, own.getMetadata());
+        assertNull(own.getLocator());
+
+        Metadata topLevel = new Metadata();
+        assertSame(topLevel, ChunkTarget.of(unit(topLevel, 
null)).getMetadata());
+        assertSame(topLevel, ChunkTarget.self(topLevel).getMetadata());
+    }
+
     @Test
-    public void testInlineAndRenderingLiftToParent() throws Exception {
+    public void testInParseInlineAndRenderingLiftToParent() {
         Metadata parent = new Metadata();
         for (String type : List.of("INLINE", "RENDERING")) {
             Metadata child = child(type, "/1", "image1.png");
@@ -63,7 +98,7 @@ public class ChunkTargetTest {
     }
 
     @Test
-    public void testAttachmentTopLevelAndNoParentKeepTheirOwn() {
+    public void testInParseAttachmentUnnumberedAndOrphanKeepTheirOwn() {
         Metadata parent = new Metadata();
         Metadata attachment = child("ATTACHMENT", "/2", "report.pdf");
         assertSame(attachment, ChunkTarget.resolve(attachment, 
withParent(parent)).getMetadata());
@@ -72,22 +107,21 @@ public class ChunkTargetTest {
         Metadata untyped = child(null, "/3", "mystery.bin");
         assertSame(untyped, ChunkTarget.resolve(untyped, 
withParent(parent)).getMetadata());
 
+        Metadata unnumbered = child("INLINE", null, "image3.png");
+        assertSame(unnumbered, ChunkTarget.resolve(unnumbered, 
withParent(parent)).getMetadata());
+
         Metadata orphan = child("INLINE", "/4", "image4.png");
         assertSame(orphan, ChunkTarget.resolve(orphan, new 
ParseContext()).getMetadata());
-
-        Metadata topLevel = new Metadata();
-        assertSame(topLevel, ChunkTarget.resolve(topLevel, 
withParent(parent)).getMetadata());
-        assertSame(topLevel, ChunkTarget.self(topLevel).getMetadata());
+        assertSame(orphan, ChunkTarget.resolve(orphan, 
withParent(null)).getMetadata());
     }
 
     @Test
     public void testWriteTagsAndAppends() throws Exception {
         Metadata parent = new Metadata();
-        ParseContext context = withParent(parent);
         for (int i = 1; i <= 2; i++) {
             Chunk chunk = new Chunk(null, new Locators());
             chunk.setVector(new float[]{i});
-            ChunkTarget.resolve(child("INLINE", "/" + i, "image" + i + 
".png"), context)
+            ChunkTarget.of(unit(child("INLINE", "/" + i, "image" + i + 
".png"), parent))
                     .write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
         }
         List<Chunk> chunks = 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
index 575905e088..60924e58e5 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
@@ -34,6 +35,8 @@ import java.net.Socket;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -127,6 +130,33 @@ public class PipesClientTest {
                             FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
             metadata = pipesResult.emitData().getMetadataList().get(0);
             assertEquals("MOCK-TEXT", metadata.get(MockTask.MARKER_KEY));
+
+            // CONCATENATE hands back one object: a result two levels down 
lands on it
+            ByteArrayOutputStream inner = new ByteArrayOutputStream();
+            try (ZipOutputStream out = new ZipOutputStream(inner)) {
+                out.putNextEntry(new ZipEntry("test.png"));
+                Files.copy(inputDir.resolve("test.png"), out);
+                out.closeEntry();
+            }
+            Path zip = inputDir.resolve("pictures.zip");
+            try (ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(zip))) {
+                out.putNextEntry(new ZipEntry("inner.zip"));
+                out.write(inner.toByteArray());
+                out.closeEntry();
+            }
+            ParseContext concatenate = new ParseContext();
+            concatenate.set(ParseMode.class, ParseMode.CONCATENATE);
+            // the zip's entry names are text: keep the TEXT stage from 
overwriting the marker
+            concatenate.setJsonConfig("inference", "{\"bindings\": 
[\"mock-images\"]}");
+            pipesResult = pipesClient.process(
+                    new FetchEmitTuple("pictures.zip", new 
FetchKey(fetcherName, "pictures.zip"),
+                            new EmitKey(), new Metadata(), concatenate,
+                            FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+            assertEquals(1, pipesResult.emitData().getMetadataList().size());
+            metadata = pipesResult.emitData().getMetadataList().get(0);
+            assertEquals("application/zip", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+            assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+            assertEquals("1", metadata.get(MockTask.UNITS_KEY));
         }
     }
 
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
index 9d1b83ae2a..5ee2346ebf 100644
--- 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
@@ -48,12 +48,16 @@ import org.apache.pdfbox.pdmodel.font.PDType1Font;
 import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
 import org.junit.jupiter.api.Test;
 
+import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.mock.MockTask;
+import org.apache.tika.serialization.JsonMetadata;
 import org.apache.tika.serialization.JsonMetadataList;
 import org.apache.tika.server.core.CXFTestBase;
 import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.writer.JSONMessageBodyWriter;
 import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
 
 /** A config-named enricher reaches the forked worker and its output comes 
back (TIKA-4872). */
@@ -63,15 +67,17 @@ public class InferenceBindingsWireTest extends CXFTestBase {
 
     @Override
     protected void setUpResources(JAXRSServerFactoryBean sf) {
-        sf.setResourceClasses(RecursiveMetadataResource.class);
+        sf.setResourceClasses(RecursiveMetadataResource.class, 
TikaResource.class);
         sf.setResourceProvider(RecursiveMetadataResource.class,
                 new SingletonResourceProvider(new 
RecursiveMetadataResource(tikaResource)));
+        sf.setResourceProvider(TikaResource.class, new 
SingletonResourceProvider(tikaResource));
     }
 
     @Override
     protected void setUpProviders(JAXRSServerFactoryBean sf) {
         List<Object> providers = new ArrayList<>();
         providers.add(new MetadataListMessageBodyWriter());
+        providers.add(new JSONMessageBodyWriter());
         sf.setProviders(providers);
     }
 
@@ -160,6 +166,47 @@ public class InferenceBindingsWireTest extends CXFTestBase 
{
         }
     }
 
+    /** /tika as JSON is one object: a picture two containers down puts its 
result on it. */
+    @Test
+    public void testSingleObjectOutputCarriesEmbeddedResults() throws 
Exception {
+        ByteArrayOutputStream png = new ByteArrayOutputStream();
+        ImageIO.write(new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB), 
"png", png);
+        ByteArrayOutputStream inner = new ByteArrayOutputStream();
+        try (ZipOutputStream out = new ZipOutputStream(inner)) {
+            out.putNextEntry(new ZipEntry("picture.png"));
+            out.write(png.toByteArray());
+            out.closeEntry();
+        }
+        ByteArrayOutputStream outer = new ByteArrayOutputStream();
+        try (ZipOutputStream out = new ZipOutputStream(outer)) {
+            out.putNextEntry(new ZipEntry("inner.zip"));
+            out.write(inner.toByteArray());
+            out.closeEntry();
+        }
+        // the entry names are text: select the images binding so TEXT does 
not overwrite the marker
+        String config = """
+                { "parse-context": { "inference": { "bindings": 
["mock-images"] } } }
+                """;
+        ContentDisposition fileCd = new ContentDisposition(
+                "form-data; name=\"file\"; filename=\"outer.zip\"");
+        Attachment fileAtt = new Attachment("file", new 
ByteArrayInputStream(outer.toByteArray()), fileCd);
+        Attachment configAtt = new Attachment("config", "application/json",
+                new ByteArrayInputStream(config.getBytes(UTF_8)));
+
+        Response response = WebClient
+                .create(endPoint + "/tika/config/json")
+                .type("multipart/form-data")
+                .accept("application/json")
+                .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+
+        assertEquals(200, response.getStatus());
+        Metadata metadata = JsonMetadata.fromJson(
+                new InputStreamReader((InputStream) response.getEntity(), 
UTF_8));
+        assertEquals("application/zip", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+        assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+    }
+
     private static byte[] twoPagePdf() throws IOException {
         try (PDDocument document = new PDDocument()) {
             for (int i = 1; i <= 2; i++) {

Reply via email to